From d0f3a6839eaf3f4039ed87759606b8afdb9ebfff Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 30 Apr 2026 11:07:53 +0200 Subject: [PATCH] Make launcher panel real TTY before spawning agents The static panel already communicated selection intent, but --panel still fell through to scripted dry-run output. Route TTY invocations through a raw-mode controller so keyboard selection happens in the terminal before dry-run or launch execution. Constraint: Non-TTY and JSON flows must keep existing deterministic output. Rejected: Replacing static renderer | tests and scripted users depend on stable text output. Confidence: high Scope-risk: moderate Directive: Keep interactive key handling in pure reducer helpers so CLI behavior remains testable without a TTY. Tested: node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js test/cli-args-dispatch.test.js test/agents-start.test.js Tested: openspec validate agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55 --type change --strict Tested: git diff --check --- .../.openspec.yaml | 2 + .../proposal.md | 16 ++ .../specs/agents-interactive-launcher/spec.md | 24 +++ .../tasks.md | 26 +++ src/agents/selection-panel.js | 155 +++++++++++++++++- src/agents/start.js | 139 ++++++++++++++++ src/cli/main.js | 8 + test/agents-selection-panel.test.js | 29 ++++ test/agents-start-dry-run.test.js | 75 +++++++++ test/cli-args-dispatch.test.js | 1 + 10 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/.openspec.yaml create mode 100644 openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/proposal.md create mode 100644 openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/specs/agents-interactive-launcher/spec.md create mode 100644 openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/tasks.md diff --git a/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/.openspec.yaml b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/.openspec.yaml new file mode 100644 index 00000000..80a7e6c7 --- /dev/null +++ b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/.openspec.yaml @@ -0,0 +1,2 @@ +tier: T2 +change: agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55 diff --git a/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/proposal.md b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/proposal.md new file mode 100644 index 00000000..c903ad85 --- /dev/null +++ b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/proposal.md @@ -0,0 +1,16 @@ +# Interactive dmux launcher panel + +## Why + +`gx agents start --panel` currently prints a static panel and then immediately prints launch plans. The panel advertises keyboard controls, but operators cannot actually use arrows, Space, plus/minus, Enter, or ESC in the terminal the way dmux-style launchers do. + +## What Changes + +- Make `gx agents start --panel` open an interactive terminal panel when stdin/stdout are TTYs. +- Keep non-TTY and scripted behavior unchanged. +- Let Enter either print dry-run plans or create lanes depending on `--dry-run`. +- Keep ESC/Ctrl-C as cancel without creating work. + +## Impact + +The CLI start path and panel renderer gain interactive behavior. Existing dry-run output remains available for scripts and tests. diff --git a/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/specs/agents-interactive-launcher/spec.md b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/specs/agents-interactive-launcher/spec.md new file mode 100644 index 00000000..ed33cba4 --- /dev/null +++ b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/specs/agents-interactive-launcher/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Panel launches interactively on TTY + +`gx agents start --panel` SHALL open an interactive terminal launcher when stdin and stdout are TTYs. + +#### Scenario: operator changes Codex account count before dry-run launch + +- **WHEN** an operator runs `gx agents start "fix auth tests" --panel --codex-accounts 1 --dry-run` in a TTY +- **AND** presses `+` +- **AND** presses Enter +- **THEN** the command SHALL print dry-run plans for two Codex lanes +- **AND** it SHALL NOT create branches, worktrees, session metadata, or agent processes. + +#### Scenario: scripted panel output stays static + +- **WHEN** `gx agents start "fix auth tests" --panel --codex-accounts 3 --dry-run` runs without a TTY +- **THEN** the command SHALL keep printing the static panel and dry-run plans as before. + +#### Scenario: operator cancels interactive panel + +- **WHEN** an operator presses ESC or Ctrl-C in the interactive panel +- **THEN** the command SHALL exit with cancellation status +- **AND** it SHALL NOT create branches, worktrees, session metadata, or agent processes. diff --git a/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/tasks.md b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/tasks.md new file mode 100644 index 00000000..34e6da18 --- /dev/null +++ b/openspec/changes/agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55/tasks.md @@ -0,0 +1,26 @@ +# Tasks + +## 1. Spec + +- [x] 1.1 Capture interactive terminal-panel behavior. + +## 2. Tests + +- [x] 2.1 Cover panel key handling. +- [x] 2.2 Cover interactive dry-run launch output. + +## 3. Implementation + +- [x] 3.1 Add panel state and key reducer. +- [x] 3.2 Route TTY `gx agents start --panel` through the interactive controller. +- [x] 3.3 Preserve non-TTY/static dry-run output. + +## 4. Verification + +- [x] 4.1 Run focused Node tests. +- [x] 4.2 Validate OpenSpec. + +## 5. Cleanup + +- [ ] 5.1 Commit, push, PR, merge, and cleanup the agent worktree. +- [ ] 5.2 Record merged PR URL and final cleanup evidence. diff --git a/src/agents/selection-panel.js b/src/agents/selection-panel.js index 62681e1b..cc2c5767 100644 --- a/src/agents/selection-panel.js +++ b/src/agents/selection-panel.js @@ -72,6 +72,139 @@ function countForAgent(selections, agentId) { return selection ? selection.count : 0; } +function countsFromSelections(selections) { + const counts = {}; + for (const selection of selections) { + counts[selection.agent.id] = selection.count; + } + return counts; +} + +function clampIndex(index, length) { + if (length <= 0) return 0; + if (!Number.isInteger(index)) return 0; + return Math.max(0, Math.min(index, length - 1)); +} + +function createAgentSelectionPanelState(options = {}) { + const definitions = getAgentDefinitions(); + const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS; + const selections = normalizeAgentSelections({ ...options, maxSelected }); + const counts = countsFromSelections(selections); + const firstSelectedIndex = definitions.findIndex((agent) => (counts[agent.id] || 0) > 0); + return { + task: options.task || '', + base: options.base || '', + claims: Array.isArray(options.claims) ? [...options.claims] : [], + maxSelected, + focusIndex: firstSelectedIndex >= 0 ? firstSelectedIndex : 0, + counts, + message: '', + }; +} + +function selectionsFromPanelState(state = {}) { + const counts = state.counts || {}; + return getAgentDefinitions() + .map((agent) => ({ + agent, + count: Number.isInteger(counts[agent.id]) ? counts[agent.id] : 0, + })) + .filter((selection) => selection.count > 0); +} + +function selectedCountFromPanelState(state = {}) { + return selectedAgentCount(selectionsFromPanelState(state)); +} + +function focusedAgent(state = {}) { + const definitions = getAgentDefinitions(); + return definitions[clampIndex(state.focusIndex, definitions.length)] || definitions[0]; +} + +function withCount(state, agentId, count, message = '') { + return { + ...state, + counts: { + ...(state.counts || {}), + [agentId]: Math.max(0, count), + }, + message, + }; +} + +function normalizePanelKey(value) { + if (!value) return ''; + const raw = Buffer.isBuffer(value) ? value.toString('utf8') : String(value); + if (raw === '\u0003') return 'ctrl-c'; + if (raw === '\u001b') return 'escape'; + if (raw === '\r' || raw === '\n') return 'enter'; + if (raw === '\u001b[A') return 'up'; + if (raw === '\u001b[B') return 'down'; + return raw.toLowerCase(); +} + +function applyAgentSelectionKey(state = {}, rawKey) { + const definitions = getAgentDefinitions(); + const current = { + ...state, + focusIndex: clampIndex(state.focusIndex, definitions.length), + counts: { ...(state.counts || {}) }, + message: '', + }; + const key = normalizePanelKey(rawKey); + + if (key === 'ctrl-c' || key === 'escape' || key === 'q') { + return { state: current, action: 'cancel' }; + } + if (key === 'enter') { + if (selectedCountFromPanelState(current) <= 0) { + return { state: { ...current, message: 'Select at least one agent before launch.' }, action: 'render' }; + } + return { state: current, action: 'launch' }; + } + if (key === 'up' || key === 'k') { + return { + state: { + ...current, + focusIndex: (current.focusIndex - 1 + definitions.length) % definitions.length, + }, + action: 'render', + }; + } + if (key === 'down' || key === 'j') { + return { + state: { + ...current, + focusIndex: (current.focusIndex + 1) % definitions.length, + }, + action: 'render', + }; + } + + const codexCount = current.counts.codex || 0; + const selectedCount = selectedCountFromPanelState(current); + if (key === '+') { + if (selectedCount >= current.maxSelected) { + return { state: { ...current, message: `Selected agent count cannot exceed ${current.maxSelected}.` }, action: 'render' }; + } + return { state: withCount(current, 'codex', codexCount + 1), action: 'render' }; + } + if (key === '-') { + return { state: withCount(current, 'codex', Math.max(0, codexCount - 1)), action: 'render' }; + } + if (key === ' ' || key === 'space') { + const agent = focusedAgent(current); + const nextCount = current.counts[agent.id] > 0 ? 0 : 1; + if (nextCount > 0 && selectedCount >= current.maxSelected) { + return { state: { ...current, message: `Selected agent count cannot exceed ${current.maxSelected}.` }, action: 'render' }; + } + return { state: withCount(current, agent.id, nextCount), action: 'render' }; + } + + return { state: current, action: 'render' }; +} + function padLine(value, width) { const text = String(value || ''); if (text.length >= width) return text.slice(0, width); @@ -107,7 +240,8 @@ function renderAgentSelectionPanel(options = {}) { const count = countForAgent(selections, agent.id); const marker = count > 0 ? '●' : '○'; const suffix = count > 1 ? ` x${count}` : ''; - return `${marker} ${agent.label} ${agent.shortLabel.toLowerCase()}${suffix}`; + const focus = options.focusedAgentId === agent.id ? '› ' : ''; + return `${focus}${marker} ${agent.label} ${agent.shortLabel.toLowerCase()}${suffix}`; }), '', 'Settings', @@ -115,18 +249,35 @@ function renderAgentSelectionPanel(options = {}) { `base: ${options.base || 'current branch'}`, `Codex accounts: ${codexAccounts}`, `claims: ${claims}`, + options.message ? `status: ${options.message}` : null, '', '↑/↓ navigate · Space toggle · +/- Codex accounts · Enter launch · ESC cancel', - ]; + ].filter((row) => row !== null); return `${framePanel('Select Agent(s)', rows)}\n`; } +function renderInteractiveAgentSelectionPanel(state = {}) { + return renderAgentSelectionPanel({ + task: state.task, + base: state.base, + claims: state.claims, + maxSelected: state.maxSelected, + selections: selectionsFromPanelState(state), + focusedAgentId: focusedAgent(state)?.id, + message: state.message, + }); +} + module.exports = { + applyAgentSelectionKey, + createAgentSelectionPanelState, DEFAULT_MAX_SELECTED_AGENTS, countForAgent, framePanel, normalizeAgentSelections, parseAgentSelectionSpec, + renderInteractiveAgentSelectionPanel, renderAgentSelectionPanel, + selectionsFromPanelState, selectedAgentCount, }; diff --git a/src/agents/start.js b/src/agents/start.js index 92a1c084..41c3cf3d 100644 --- a/src/agents/start.js +++ b/src/agents/start.js @@ -8,8 +8,12 @@ const { currentBranchName } = require('../git'); const { buildAgentLaunchCommand } = require('./launch'); const { resolveAgent } = require('./registry'); const { + applyAgentSelectionKey, + createAgentSelectionPanelState, normalizeAgentSelections, + renderInteractiveAgentSelectionPanel, renderAgentSelectionPanel, + selectionsFromPanelState, selectedAgentCount, } = require('./selection-panel'); const { @@ -168,6 +172,9 @@ function dryRunStart(options, repoRoot) { if (plans.length === 1 && !options.panel) { return renderDryRunPlan(plans[0]); } + if (options.noPanel) { + return plans.map(renderDryRunPlan).join('\n\n'); + } return [ renderAgentSelectionPanel({ @@ -180,6 +187,135 @@ function dryRunStart(options, repoRoot) { ].join('\n\n'); } +function panelSelectionSpecs(state) { + return selectionsFromPanelState(state).map((selection) => `${selection.agent.id}:${selection.count}`); +} + +function panelOptions(options, state) { + const specs = panelSelectionSpecs(state); + const first = selectionsFromPanelState(state)[0]; + return { + ...options, + agent: first ? first.agent.id : options.agent, + count: 1, + agentSelectionSpecs: specs, + }; +} + +function executePanelSelection(repoRoot, options, state, deps = {}) { + const selectedOptions = panelOptions(options, state); + if (selectedOptions.dryRun) { + return { + status: 0, + stdout: dryRunStart({ ...selectedOptions, panel: false, noPanel: true }, repoRoot), + stderr: '', + }; + } + const startRunner = deps.startRunner || startAgentLane; + return startRunner(repoRoot, selectedOptions, deps); +} + +function writeStream(stream, value) { + if (stream && typeof stream.write === 'function' && value) { + stream.write(String(value)); + } +} + +function shouldUseInteractivePanel(options = {}, stdin = process.stdin, stdout = process.stdout) { + return Boolean(options.panel && options.task && stdin && stdin.isTTY && stdout && stdout.isTTY); +} + +function startInteractiveAgentPanel(repoRoot, options, deps = {}) { + const stdin = deps.stdin || process.stdin; + const stdout = deps.stdout || process.stdout; + const stderr = deps.stderr || process.stderr; + const onDone = typeof deps.onDone === 'function' ? deps.onDone : null; + let state = createAgentSelectionPanelState(options); + let stopped = false; + let rawModeEnabled = false; + + function paint() { + if (stdout && stdout.isTTY) { + writeStream(stdout, '\x1b[?25l\x1b[H\x1b[2J\x1b[3J'); + } + writeStream(stdout, renderInteractiveAgentSelectionPanel(state)); + } + + function finish(result) { + if (onDone) onDone(result); + return result; + } + + function stop() { + if (stopped) return; + stopped = true; + if (stdin && typeof stdin.off === 'function') { + stdin.off('data', onData); + } else if (stdin && typeof stdin.removeListener === 'function') { + stdin.removeListener('data', onData); + } + if (rawModeEnabled && typeof stdin.setRawMode === 'function') { + stdin.setRawMode(false); + } + if (stdout && stdout.isTTY) { + writeStream(stdout, '\x1b[?25h'); + } + } + + function launch() { + stop(); + writeStream(stdout, '\n'); + const result = executePanelSelection(repoRoot, options, state, deps); + writeStream(stdout, result.stdout); + writeStream(stderr, result.stderr); + return finish(result); + } + + function cancel() { + stop(); + const result = { + status: 130, + stdout: `[${TOOL_NAME}] Agent launch cancelled.\n`, + stderr: '', + }; + writeStream(stdout, '\n'); + writeStream(stdout, result.stdout); + return finish(result); + } + + function dispatch(rawKey) { + const next = applyAgentSelectionKey(state, rawKey); + state = next.state; + if (next.action === 'launch') return launch(); + if (next.action === 'cancel') return cancel(); + paint(); + return null; + } + + function onData(chunk) { + dispatch(chunk); + } + + if (!shouldUseInteractivePanel(options, stdin, stdout)) { + return finish(executePanelSelection(repoRoot, options, state, deps)); + } + + if (typeof stdin.setEncoding === 'function') stdin.setEncoding('utf8'); + if (typeof stdin.setRawMode === 'function') { + stdin.setRawMode(true); + rawModeEnabled = true; + } + if (typeof stdin.resume === 'function') stdin.resume(); + if (typeof stdin.on === 'function') stdin.on('data', onData); + paint(); + + return { + dispatch, + stop, + getState: () => state, + }; +} + function isSpawnFailure(result) { return Boolean(result?.error) && typeof result?.status !== 'number'; } @@ -389,11 +525,14 @@ module.exports = { buildStartPlan, buildRecoveryLines, dryRunStart, + executePanelSelection, extractAgentBranchStartMetadata, agentSessionIdForBranch, renderDryRunPlan, sanitizeSlug, + shouldUseInteractivePanel, startSingleAgentLane, + startInteractiveAgentPanel, writeAgentSession, startAgentLane, }; diff --git a/src/cli/main.js b/src/cli/main.js index 9e0f7db6..6a32ef46 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -2673,6 +2673,14 @@ function agents(rawArgs) { } if (options.subcommand === 'start') { + if (options.task && agentsStart.shouldUseInteractivePanel(options, process.stdin, process.stdout)) { + agentsStart.startInteractiveAgentPanel(repoRoot, options, { + onDone(result) { + process.exitCode = result.status; + }, + }); + return; + } if (options.dryRun) { const output = agentsStart.dryRunStart(options, repoRoot); process.stdout.write(output.endsWith('\n') ? output : `${output}\n`); diff --git a/test/agents-selection-panel.test.js b/test/agents-selection-panel.test.js index adb3839b..58fc5017 100644 --- a/test/agents-selection-panel.test.js +++ b/test/agents-selection-panel.test.js @@ -2,10 +2,14 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { + applyAgentSelectionKey, countForAgent, + createAgentSelectionPanelState, normalizeAgentSelections, parseAgentSelectionSpec, + renderInteractiveAgentSelectionPanel, renderAgentSelectionPanel, + selectionsFromPanelState, selectedAgentCount, } = require('../src/agents/selection-panel'); @@ -47,3 +51,28 @@ test('renderAgentSelectionPanel shows selected count and codex account setting', assert.match(output, /base: main/); assert.match(output, /claims: src\/auth\.js/); }); + +test('interactive panel keys move focus, toggle agents, and adjust codex accounts', () => { + let state = createAgentSelectionPanelState({ + task: 'repair auth', + base: 'main', + agentSelectionSpecs: ['codex:2'], + }); + + assert.equal(selectedAgentCount(selectionsFromPanelState(state)), 2); + assert.match(renderInteractiveAgentSelectionPanel(state), /› ● Codex cx x2/); + + state = applyAgentSelectionKey(state, '\u001b[B').state; + assert.match(renderInteractiveAgentSelectionPanel(state), /› ○ Claude Code cc/); + + state = applyAgentSelectionKey(state, ' ').state; + assert.equal(countForAgent(selectionsFromPanelState(state), 'claude'), 1); + + state = applyAgentSelectionKey(state, '+').state; + assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 3); + + state = applyAgentSelectionKey(state, '-').state; + assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 2); + assert.equal(applyAgentSelectionKey(state, '\r').action, 'launch'); + assert.equal(applyAgentSelectionKey(state, '\u001b').action, 'cancel'); +}); diff --git a/test/agents-start-dry-run.test.js b/test/agents-start-dry-run.test.js index 585119a4..ad5fdd3f 100644 --- a/test/agents-start-dry-run.test.js +++ b/test/agents-start-dry-run.test.js @@ -9,6 +9,9 @@ const { initRepo, seedCommit, } = require('./helpers/install-test-helpers'); +const { EventEmitter } = require('node:events'); + +const { startInteractiveAgentPanel } = require('../src/agents/start'); test('gx agents start dry-run prints the planned codex branch, worktree, and launch without side effects', () => { const repoDir = initRepo(); @@ -147,3 +150,75 @@ test('gx agents start --dry-run --json emits Colony-ready launch plan', () => { 'colony.task_id': '42', }); }); + +class FakeInput extends EventEmitter { + constructor() { + super(); + this.isTTY = true; + this.rawModes = []; + this.encodings = []; + this.resumed = false; + } + + setEncoding(encoding) { + this.encodings.push(encoding); + } + + setRawMode(enabled) { + this.rawModes.push(enabled); + } + + resume() { + this.resumed = true; + } +} + +test('interactive launcher panel handles keys before emitting dry-run plans', () => { + const input = new FakeInput(); + const stdout = { + isTTY: true, + chunks: [], + write(chunk) { + this.chunks.push(String(chunk)); + }, + }; + const stderr = { + chunks: [], + write(chunk) { + this.chunks.push(String(chunk)); + }, + }; + let done = null; + + const controller = startInteractiveAgentPanel('/repo', { + task: 'fix auth tests', + agent: 'codex', + base: 'main', + count: 1, + panel: true, + dryRun: true, + claims: [], + }, { + stdin: input, + stdout, + stderr, + onDone(result) { + done = result; + }, + }); + + assert.equal(input.resumed, true); + assert.deepEqual(input.rawModes, [true]); + assert.match(stdout.chunks.join(''), /Select Agent\(s\)/); + + controller.dispatch('+'); + controller.dispatch('\r'); + + assert.deepEqual(input.rawModes, [true, false]); + assert.equal(done.status, 0); + assert.equal(stderr.chunks.join(''), ''); + const output = stdout.chunks.join(''); + assert.match(output, /Codex cx x2/); + assert.match(output, /branch: agent\/codex\/fix-auth-tests-codex-01-/); + assert.match(output, /branch: agent\/codex\/fix-auth-tests-codex-02-/); +}); diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js index 804e6ccf..aa6f0e53 100644 --- a/test/cli-args-dispatch.test.js +++ b/test/cli-args-dispatch.test.js @@ -109,6 +109,7 @@ test('parseAgentsArgs applies interval overrides and validates the subcommand', agent: '', base: '', claims: [], + metadata: {}, count: 1, agentSelectionSpecs: [], panel: false,