Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Plain gx cockpit welcome

## Why

Plain interactive `gx` should open the GitGuardex cockpit welcome/control view. Operators currently need to remember `gx cockpit` or get the old status/launcher behavior, which makes the cockpit feel secondary even though it is now the primary dmux-style surface.

## What Changes

- Route no-argument interactive `gx` to the cockpit control launcher.
- Prefer Kitty when remote control is available, then fall back to tmux, then fall back to an inline cockpit render.
- Preserve non-interactive no-argument status output and explicit `gx status`.
- Add `GUARDEX_LEGACY_STATUS=1` to force no-argument `gx` back to status output.

## Impact

The change is limited to no-argument CLI dispatch and cockpit launch helpers. It does not alter explicit `gx cockpit`, `gx status`, agent branch creation, locks, or finish behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## ADDED Requirements

### Requirement: plain interactive gx opens cockpit

Guardex SHALL route a no-argument `gx` invocation from an interactive terminal to the GitGuardex cockpit control view.

#### Scenario: interactive no-argument launch

- **GIVEN** stdin and stdout are TTYs
- **AND** `GUARDEX_LEGACY_STATUS` is not enabled
- **WHEN** the user runs `gx`
- **THEN** Guardex SHALL open the cockpit control view instead of printing status output.

#### Scenario: non-interactive no-argument launch

- **GIVEN** stdin or stdout is not a TTY
- **WHEN** the user runs `gx`
- **THEN** Guardex SHALL print the existing compact status output.

#### Scenario: legacy status escape hatch

- **GIVEN** stdin and stdout are TTYs
- **AND** `GUARDEX_LEGACY_STATUS=1`
- **WHEN** the user runs `gx`
- **THEN** Guardex SHALL print the existing status output instead of opening the cockpit.

#### Scenario: explicit status command

- **WHEN** the user runs `gx status`
- **THEN** Guardex SHALL print status output.

### Requirement: default cockpit launch falls back safely

Guardex SHALL prefer Kitty for the default interactive cockpit launch when Kitty remote control is available, then fall back to tmux, then fall back to an inline cockpit control render.

#### Scenario: Kitty unavailable

- **GIVEN** Kitty remote control is unavailable
- **WHEN** the default cockpit launcher runs
- **THEN** Guardex SHALL try the tmux cockpit backend.

#### Scenario: terminal backends unavailable

- **GIVEN** Kitty and tmux cockpit launch both fail
- **WHEN** the default cockpit launcher runs
- **THEN** Guardex SHALL render the cockpit control view inline in the current terminal.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Tasks

## 1. Spec

- [x] 1.1 Capture plain interactive `gx` cockpit behavior.

## 2. Tests

- [x] 2.1 Cover interactive no-argument `gx` opening cockpit.
- [x] 2.2 Cover non-interactive no-argument `gx` printing status.
- [x] 2.3 Cover `GUARDEX_LEGACY_STATUS=1` preserving status output.
- [x] 2.4 Cover explicit `gx status` output.

## 3. Implementation

- [x] 3.1 Route interactive no-argument `gx` through cockpit default launch.
- [x] 3.2 Keep non-TTY and legacy env paths on status.
- [x] 3.3 Add cockpit default fallback order: Kitty, tmux, inline render.

## 4. Verification

- [x] 4.1 Run focused Node tests.
- Evidence: `node --test test/default-gx-cockpit.test.js test/cockpit-command.test.js test/cli-args-dispatch.test.js` passed (`23/23`).
- [x] 4.2 Validate OpenSpec change.
- Evidence: `openspec validate agent-codex-plain-gx-cockpit-welcome-2026-05-01-00-21 --type change --strict` passed.
- [x] 4.3 Run diff whitespace check.
- Evidence: `git diff --check` passed.
- [x] 4.4 Run full Node test suite.
- Evidence: `npm test` passed (`492` passed, `1` skipped, `0` failed).

## 5. Cleanup

- [ ] 5.1 Run the finish pipeline: `gx branch finish --branch agent/codex/plain-gx-cockpit-welcome-2026-05-01-00-21 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 5.2 Record PR URL, final `MERGED` state, and sandbox cleanup evidence.
16 changes: 9 additions & 7 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,10 @@ function isInteractiveTerminal() {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}

function legacyDefaultStatusEnabled() {
return envFlagIsTruthy(process.env.GUARDEX_LEGACY_STATUS);
}

function parseAutoApproval(name) {
const raw = process.env[name];
if (raw == null) return null;
Expand Down Expand Up @@ -3749,14 +3753,12 @@ async function main() {
const args = process.argv.slice(2);

if (args.length === 0) {
if (isInteractiveTerminal()) {
const options = parseAgentsArgs(['start', '--panel']);
const repoRoot = resolveRepoRoot(options.target);
agentsStart.startInteractiveAgentPanel(repoRoot, options, {
onDone(result) {
process.exitCode = result.status;
},
if (isInteractiveTerminal() && !legacyDefaultStatusEnabled()) {
cockpitModule.openDefaultCockpit({
resolveRepoRoot,
toolName: TOOL_NAME,
});
process.exitCode = 0;
return;
}
toolchainModule.maybeSelfUpdateBeforeStatus();
Expand Down
169 changes: 138 additions & 31 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { normalizeBackendName, selectTerminalBackend } = require('../terminal');

const DEFAULT_SESSION_NAME = 'guardex';
const DEFAULT_BACKEND = 'tmux';
const DEFAULT_INTERACTIVE_BACKEND = 'auto';

function parseCockpitArgs(rawArgs = []) {
const options = {
Expand Down Expand Up @@ -122,6 +123,138 @@ function cockpitControlCommand(repoRoot, options = {}) {
return `gx cockpit control --target ${shellQuote(repoRoot)}${refresh}`;
}

function terminalBackendOptionsFromDeps(deps = {}) {
const terminalBackendOptions = { ...(deps.terminalBackendOptions || {}) };
if (deps.terminalBackends && deps.terminalBackends.kitty) {
terminalBackendOptions.kittyBackend = deps.terminalBackends.kitty;
}
if (deps.terminalBackends && deps.terminalBackends.tmux) {
terminalBackendOptions.tmuxBackend = deps.terminalBackends.tmux;
}
if (deps.tmux) {
terminalBackendOptions.tmux = { tmux: deps.tmux };
}
return terminalBackendOptions;
}

function writeOpenedCockpitMessage({ backend, action, options, repoRoot, controlCommand, stdout, toolName }) {
if (backend.name === 'tmux' && action === 'attached') {
stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`);
return;
}

if (backend.name === 'tmux') {
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
} else {
stdout.write(`[${toolName}] Created ${backend.name} cockpit window '${options.sessionName}' in ${repoRoot}.\n`);
}
stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`);
}

function openWithBackend(backend, options, repoRoot, controlCommand, deps = {}) {
const stdout = deps.stdout || process.stdout;
const toolName = deps.toolName || 'gitguardex';
const result = backend.openCockpitLayout({
repoRoot,
sessionName: options.sessionName,
command: controlCommand,
attach: options.attach,
});
const action = result && result.action ? result.action : 'created';

writeOpenedCockpitMessage({ backend, action, options, repoRoot, controlCommand, stdout, toolName });
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
}

function backendAvailable(backend) {
if (!backend || typeof backend.isAvailable !== 'function') return true;
try {
return Boolean(backend.isAvailable());
} catch (_error) {
return false;
}
}

function defaultCockpitBackends(preferredBackend, terminalBackendOptions = {}) {
const preferred = normalizeBackendName(preferredBackend || DEFAULT_INTERACTIVE_BACKEND, DEFAULT_INTERACTIVE_BACKEND);
const seen = new Set();
const candidates = [];
const add = (name, options = {}) => {
if (seen.has(name)) return;
const backend = selectTerminalBackend(name, terminalBackendOptions);
if (!backend) return;
if (options.onlyIfAvailable && !backendAvailable(backend)) return;
seen.add(name);
candidates.push(backend);
};

if (preferred === 'auto') {
add('kitty', { onlyIfAvailable: true });
add('tmux');
return candidates;
}

add(preferred);
if (preferred !== 'tmux') add('tmux');
return candidates;
}

function inlineCockpit(repoRoot, deps = {}) {
const controlHandle = control.startCockpitControl({
repoPath: repoRoot,
stdin: deps.stdin,
stdout: deps.stdout || process.stdout,
readState: deps.readState,
readSettings: deps.readSettings,
setInterval: deps.setInterval,
clearInterval: deps.clearInterval,
});
return {
action: 'rendered',
backend: 'inline',
sessionName: DEFAULT_SESSION_NAME,
repoRoot,
control: controlHandle,
};
}

function openDefaultCockpit(deps = {}) {
const {
resolveRepoRoot,
env = process.env,
} = deps;
if (typeof resolveRepoRoot !== 'function') {
throw new Error('openDefaultCockpit requires resolveRepoRoot');
}

const target = deps.target || process.cwd();
const options = {
sessionName: DEFAULT_SESSION_NAME,
backend: env.GUARDEX_COCKPIT_BACKEND || DEFAULT_INTERACTIVE_BACKEND,
attach: false,
target,
};
const repoRoot = resolveRepoRoot(target);
const controlCommand = cockpitControlCommand(repoRoot);
const terminalBackendOptions = terminalBackendOptionsFromDeps(deps);
const failures = [];

for (const backend of defaultCockpitBackends(options.backend, terminalBackendOptions)) {
try {
return openWithBackend(backend, options, repoRoot, controlCommand, deps);
} catch (error) {
failures.push({
backend: backend.name,
message: error && error.message ? error.message : String(error),
});
}
}

const result = inlineCockpit(repoRoot, deps);
result.failures = failures;
return result;
}

function render(repoPath = process.cwd()) {
return renderCockpit(readCockpitState(repoPath));
}
Expand Down Expand Up @@ -168,39 +301,10 @@ function openCockpit(rawArgs = [], deps = {}) {
const options = parseCockpitArgs(rawArgs);
const repoRoot = resolveRepoRoot(options.target);
const controlCommand = cockpitControlCommand(repoRoot);
const terminalBackendOptions = { ...(deps.terminalBackendOptions || {}) };
if (deps.terminalBackends && deps.terminalBackends.kitty) {
terminalBackendOptions.kittyBackend = deps.terminalBackends.kitty;
}
if (deps.terminalBackends && deps.terminalBackends.tmux) {
terminalBackendOptions.tmuxBackend = deps.terminalBackends.tmux;
}
if (deps.tmux) {
terminalBackendOptions.tmux = { tmux: deps.tmux };
}
const terminalBackendOptions = terminalBackendOptionsFromDeps(deps);
const backend = selectTerminalBackend(options.backend, terminalBackendOptions);

const result = backend.openCockpitLayout({
repoRoot,
sessionName: options.sessionName,
command: controlCommand,
attach: options.attach,
});
const action = result && result.action ? result.action : 'created';

if (backend.name === 'tmux' && action === 'attached') {
stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`);
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
}

if (backend.name === 'tmux') {
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
} else {
stdout.write(`[${toolName}] Created ${backend.name} cockpit window '${options.sessionName}' in ${repoRoot}.\n`);
}
stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`);

return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
return openWithBackend(backend, options, repoRoot, controlCommand, { ...deps, stdout, toolName });
}

if (require.main === module) {
Expand All @@ -213,9 +317,12 @@ if (require.main === module) {
module.exports = {
DEFAULT_SESSION_NAME,
DEFAULT_BACKEND,
DEFAULT_INTERACTIVE_BACKEND,
cockpitControlCommand,
defaultCockpitBackends,
parseCockpitArgs,
parseCockpitControlArgs,
openDefaultCockpit,
openCockpit,
render,
startCockpit,
Expand Down
Loading