From 9dc547156476727b2df554a78f58b4aea8bd0a40 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 22:19:22 +0200 Subject: [PATCH] Make cockpit preview usable without tmux Cockpit startup now supports a non-interactive render path for quick preview, and tmux startup sends the live cockpit renderer into the pane returned by tmux instead of addressing the session name ambiguously. Constraint: Users need gx cockpit --no-tmux when tmux is unavailable or unwanted. Rejected: Keep sending gx agents status to the session target | it does not render the cockpit and can target ambiguously. Confidence: high Scope-risk: narrow Directive: Keep tmux control commands targeted to pane ids, not bare session names. Tested: node --test test/cockpit-command.test.js test/tmux-session.test.js test/cockpit-render.test.js Tested: node --test test/cockpit-command.test.js test/tmux-session.test.js test/cockpit-render.test.js test/agents-launch.test.js test/agents-sessions.test.js test/cli-args-dispatch.test.js (cockpit/tmux passed; unrelated agents tests failed) Not-tested: Full npm test is blocked by unrelated baseline failures in agents-launch, agents-sessions, and cli-args-dispatch. --- src/cockpit/index.js | 40 +++++++++++++++++++++++--- src/tmux/session.js | 2 +- test/cockpit-command.test.js | 56 ++++++++++++++++++++++++++++++------ test/tmux-session.test.js | 2 +- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/cockpit/index.js b/src/cockpit/index.js index ce863f78..c057cc00 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -15,6 +15,7 @@ function parseCockpitArgs(rawArgs = []) { const options = { sessionName: DEFAULT_SESSION_NAME, attach: false, + noTmux: false, target: process.cwd(), }; @@ -24,6 +25,10 @@ function parseCockpitArgs(rawArgs = []) { options.attach = true; continue; } + if (arg === '--no-tmux') { + options.noTmux = true; + continue; + } if (arg === '--session') { const next = rawArgs[index + 1]; if (!next || next.startsWith('-')) { @@ -55,6 +60,19 @@ function parseCockpitArgs(rawArgs = []) { return options; } +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function cockpitRendererCommand(repoRoot) { + try { + const entry = require.resolve('./index'); + return `${shellQuote(process.execPath)} ${shellQuote(entry)} ${shellQuote(repoRoot)}`; + } catch (_error) { + return `gx cockpit --no-tmux --target ${shellQuote(repoRoot)}`; + } +} + function render(repoPath = process.cwd()) { return renderCockpit(readCockpitState(repoPath)); } @@ -93,9 +111,18 @@ function openCockpit(rawArgs, deps = {}) { const options = parseCockpitArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); - const controlCommand = 'gx agents status'; + if (options.noTmux) { + stdout.write(render(repoRoot)); + return { action: 'rendered', sessionName: options.sessionName, repoRoot }; + } - tmux.ensureTmuxAvailable(); + const controlCommand = cockpitRendererCommand(repoRoot); + + try { + tmux.ensureTmuxAvailable(); + } catch (error) { + throw new Error(`${error.message}\nPreview without tmux: gx cockpit --no-tmux`); + } if (tmux.sessionExists(options.sessionName)) { stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`); @@ -109,14 +136,18 @@ function openCockpit(rawArgs, deps = {}) { const detail = String(createResult.stderr || createResult.stdout || '').trim(); throw new Error(`tmux could not create session '${options.sessionName}'${detail ? `: ${detail}` : '.'}`); } - const sendResult = tmux.sendKeys(options.sessionName, controlCommand); + const paneId = String(createResult.stdout || '').trim(); + if (!paneId) { + throw new Error(`tmux did not return a control pane id for session '${options.sessionName}'.`); + } + const sendResult = tmux.sendKeys(paneId, controlCommand); if (sendResult.error) throw sendResult.error; if (sendResult.status !== 0) { const detail = String(sendResult.stderr || sendResult.stdout || '').trim(); throw new Error(`tmux could not start cockpit control pane${detail ? `: ${detail}` : '.'}`); } stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`); - stdout.write(`[${toolName}] Control pane: gx agents status\n`); + stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`); if (options.attach) { tmux.attachSession(options.sessionName); @@ -136,6 +167,7 @@ if (require.main === module) { module.exports = { DEFAULT_SESSION_NAME, parseCockpitArgs, + cockpitRendererCommand, openCockpit, render, startCockpit, diff --git a/src/tmux/session.js b/src/tmux/session.js index d42ad776..71ccfc18 100644 --- a/src/tmux/session.js +++ b/src/tmux/session.js @@ -29,7 +29,7 @@ function sessionExists(name) { } function createSession(name, cwd) { - const args = ['new-session', '-d', '-s', requireName(name)]; + const args = ['new-session', '-d', '-s', requireName(name), '-P', '-F', '#{pane_id}']; addCwd(args, cwd); return tmux.runTmux(args); } diff --git a/test/cockpit-command.test.js b/test/cockpit-command.test.js index 1ca640ba..14002c8b 100644 --- a/test/cockpit-command.test.js +++ b/test/cockpit-command.test.js @@ -23,7 +23,7 @@ test('cockpit creates the default tmux session in the repo root', () => { 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + 'if [[ "$1" == "-V" ]]; then echo "tmux 3.4"; exit 0; fi\n' + 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + - 'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "new-session" ]]; then echo "%7"; exit 0; fi\n' + 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + 'exit 9\n', ); @@ -32,14 +32,28 @@ test('cockpit creates the default tmux session in the repo root', () => { assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Created tmux session 'guardex'/); - assert.match(result.stdout, /Control pane: gx agents status/); + assert.match(result.stdout, /Control pane: .*src\/cockpit\/index\.js/); const lines = fs.readFileSync(log, 'utf8').trim().split('\n'); assert.match(lines[1], /^.* :: has-session -t guardex$/); - assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)); - assert.match(lines[3], /^.* :: send-keys -t guardex gx agents status C-m$/); + assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -P -F #\\{pane_id\\} -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)); + assert.match(lines[3], /^.* :: send-keys -t %7 .*src\/cockpit\/index\.js.* C-m$/); }); -test('cockpit attaches when the tmux session already exists', () => { +test('cockpit --no-tmux renders once without tmux', () => { + const repoDir = initRepo(); + const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`); + + const result = runNodeWithEnv(['cockpit', '--no-tmux', '--target', repoDir], repoDir, { + GUARDEX_TMUX_BIN: missingTmux, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /GitGuardex Cockpit/); + assert.match(result.stdout, new RegExp(`repo: ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(result.stdout, /No active agent sessions\./); +}); + +test('cockpit attaches to an existing tmux session', () => { const repoDir = initRepo(); const { bin, log } = fakeTmux( 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + @@ -61,13 +75,13 @@ test('cockpit attaches when the tmux session already exists', () => { assert.doesNotMatch(logged, /new-session/); }); -test('cockpit --attach creates then attaches when the session is missing', () => { +test('cockpit creates a new tmux session with an explicit control pane target', () => { const repoDir = initRepo(); const { bin, log } = fakeTmux( 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + 'if [[ "$1" == "-V" ]]; then exit 0; fi\n' + 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + - 'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "new-session" ]]; then echo "%9"; exit 0; fi\n' + 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + 'if [[ "$1" == "attach-session" ]]; then exit 0; fi\n' + 'exit 9\n', @@ -79,11 +93,34 @@ test('cockpit --attach creates then attaches when the session is missing', () => assert.equal(result.status, 0, result.stderr || result.stdout); const logged = fs.readFileSync(log, 'utf8'); - assert.match(logged, /new-session -d -s guardex-dev/); - assert.match(logged, /send-keys -t guardex-dev gx agents status C-m/); + assert.match(logged, /new-session -d -s guardex-dev -P -F #\{pane_id\}/); + assert.match(logged, /send-keys -t %9 .*src\/cockpit\/index\.js.* C-m/); assert.match(logged, /attach-session -t guardex-dev/); }); +test('cockpit creates a custom tmux session name', () => { + const repoDir = initRepo(); + const { bin, log } = fakeTmux( + 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + + 'if [[ "$1" == "-V" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + + 'if [[ "$1" == "new-session" ]]; then echo "%11"; exit 0; fi\n' + + 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + + 'exit 9\n', + ); + + const result = runNodeWithEnv(['cockpit', '--session', 'ops-cockpit', '--target', repoDir], repoDir, { + GUARDEX_TMUX_BIN: bin, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Created tmux session 'ops-cockpit'/); + const logged = fs.readFileSync(log, 'utf8'); + assert.match(logged, /has-session -t ops-cockpit/); + assert.match(logged, /new-session -d -s ops-cockpit -P -F #\{pane_id\}/); + assert.match(logged, /send-keys -t %11 /); +}); + test('cockpit reports a helpful error when tmux is unavailable', () => { const repoDir = initRepo(); const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`); @@ -92,4 +129,5 @@ test('cockpit reports a helpful error when tmux is unavailable', () => { assert.equal(result.status, 1); assert.match(result.stderr, /tmux is required for gx cockpit\. Install tmux and retry\./); + assert.match(result.stderr, /Preview without tmux: gx cockpit --no-tmux/); }); diff --git a/test/tmux-session.test.js b/test/tmux-session.test.js index 74576084..746442ff 100644 --- a/test/tmux-session.test.js +++ b/test/tmux-session.test.js @@ -36,7 +36,7 @@ test('createSession builds detached session argv with cwd', () => { tmuxSession.createSession('gx-cockpit', '/repo'); assert.deepEqual(calls, [ { - args: ['new-session', '-d', '-s', 'gx-cockpit', '-c', '/repo'], + args: ['new-session', '-d', '-s', 'gx-cockpit', '-P', '-F', '#{pane_id}', '-c', '/repo'], options: undefined, }, ]);