Skip to content

Commit b1e0a8c

Browse files
NagyViktNagyVikt
andauthored
Make launcher panel real TTY before spawning agents (#489)
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 Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 800639e commit b1e0a8c

10 files changed

Lines changed: 473 additions & 2 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
tier: T2
2+
change: agent-codex-interactive-dmux-launcher-panel-2026-04-30-10-55
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Interactive dmux launcher panel
2+
3+
## Why
4+
5+
`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.
6+
7+
## What Changes
8+
9+
- Make `gx agents start <task> --panel` open an interactive terminal panel when stdin/stdout are TTYs.
10+
- Keep non-TTY and scripted behavior unchanged.
11+
- Let Enter either print dry-run plans or create lanes depending on `--dry-run`.
12+
- Keep ESC/Ctrl-C as cancel without creating work.
13+
14+
## Impact
15+
16+
The CLI start path and panel renderer gain interactive behavior. Existing dry-run output remains available for scripts and tests.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Panel launches interactively on TTY
4+
5+
`gx agents start <task> --panel` SHALL open an interactive terminal launcher when stdin and stdout are TTYs.
6+
7+
#### Scenario: operator changes Codex account count before dry-run launch
8+
9+
- **WHEN** an operator runs `gx agents start "fix auth tests" --panel --codex-accounts 1 --dry-run` in a TTY
10+
- **AND** presses `+`
11+
- **AND** presses Enter
12+
- **THEN** the command SHALL print dry-run plans for two Codex lanes
13+
- **AND** it SHALL NOT create branches, worktrees, session metadata, or agent processes.
14+
15+
#### Scenario: scripted panel output stays static
16+
17+
- **WHEN** `gx agents start "fix auth tests" --panel --codex-accounts 3 --dry-run` runs without a TTY
18+
- **THEN** the command SHALL keep printing the static panel and dry-run plans as before.
19+
20+
#### Scenario: operator cancels interactive panel
21+
22+
- **WHEN** an operator presses ESC or Ctrl-C in the interactive panel
23+
- **THEN** the command SHALL exit with cancellation status
24+
- **AND** it SHALL NOT create branches, worktrees, session metadata, or agent processes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
5+
- [x] 1.1 Capture interactive terminal-panel behavior.
6+
7+
## 2. Tests
8+
9+
- [x] 2.1 Cover panel key handling.
10+
- [x] 2.2 Cover interactive dry-run launch output.
11+
12+
## 3. Implementation
13+
14+
- [x] 3.1 Add panel state and key reducer.
15+
- [x] 3.2 Route TTY `gx agents start --panel` through the interactive controller.
16+
- [x] 3.3 Preserve non-TTY/static dry-run output.
17+
18+
## 4. Verification
19+
20+
- [x] 4.1 Run focused Node tests.
21+
- [x] 4.2 Validate OpenSpec.
22+
23+
## 5. Cleanup
24+
25+
- [ ] 5.1 Commit, push, PR, merge, and cleanup the agent worktree.
26+
- [ ] 5.2 Record merged PR URL and final cleanup evidence.

src/agents/selection-panel.js

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,139 @@ function countForAgent(selections, agentId) {
7272
return selection ? selection.count : 0;
7373
}
7474

75+
function countsFromSelections(selections) {
76+
const counts = {};
77+
for (const selection of selections) {
78+
counts[selection.agent.id] = selection.count;
79+
}
80+
return counts;
81+
}
82+
83+
function clampIndex(index, length) {
84+
if (length <= 0) return 0;
85+
if (!Number.isInteger(index)) return 0;
86+
return Math.max(0, Math.min(index, length - 1));
87+
}
88+
89+
function createAgentSelectionPanelState(options = {}) {
90+
const definitions = getAgentDefinitions();
91+
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
92+
const selections = normalizeAgentSelections({ ...options, maxSelected });
93+
const counts = countsFromSelections(selections);
94+
const firstSelectedIndex = definitions.findIndex((agent) => (counts[agent.id] || 0) > 0);
95+
return {
96+
task: options.task || '',
97+
base: options.base || '',
98+
claims: Array.isArray(options.claims) ? [...options.claims] : [],
99+
maxSelected,
100+
focusIndex: firstSelectedIndex >= 0 ? firstSelectedIndex : 0,
101+
counts,
102+
message: '',
103+
};
104+
}
105+
106+
function selectionsFromPanelState(state = {}) {
107+
const counts = state.counts || {};
108+
return getAgentDefinitions()
109+
.map((agent) => ({
110+
agent,
111+
count: Number.isInteger(counts[agent.id]) ? counts[agent.id] : 0,
112+
}))
113+
.filter((selection) => selection.count > 0);
114+
}
115+
116+
function selectedCountFromPanelState(state = {}) {
117+
return selectedAgentCount(selectionsFromPanelState(state));
118+
}
119+
120+
function focusedAgent(state = {}) {
121+
const definitions = getAgentDefinitions();
122+
return definitions[clampIndex(state.focusIndex, definitions.length)] || definitions[0];
123+
}
124+
125+
function withCount(state, agentId, count, message = '') {
126+
return {
127+
...state,
128+
counts: {
129+
...(state.counts || {}),
130+
[agentId]: Math.max(0, count),
131+
},
132+
message,
133+
};
134+
}
135+
136+
function normalizePanelKey(value) {
137+
if (!value) return '';
138+
const raw = Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
139+
if (raw === '\u0003') return 'ctrl-c';
140+
if (raw === '\u001b') return 'escape';
141+
if (raw === '\r' || raw === '\n') return 'enter';
142+
if (raw === '\u001b[A') return 'up';
143+
if (raw === '\u001b[B') return 'down';
144+
return raw.toLowerCase();
145+
}
146+
147+
function applyAgentSelectionKey(state = {}, rawKey) {
148+
const definitions = getAgentDefinitions();
149+
const current = {
150+
...state,
151+
focusIndex: clampIndex(state.focusIndex, definitions.length),
152+
counts: { ...(state.counts || {}) },
153+
message: '',
154+
};
155+
const key = normalizePanelKey(rawKey);
156+
157+
if (key === 'ctrl-c' || key === 'escape' || key === 'q') {
158+
return { state: current, action: 'cancel' };
159+
}
160+
if (key === 'enter') {
161+
if (selectedCountFromPanelState(current) <= 0) {
162+
return { state: { ...current, message: 'Select at least one agent before launch.' }, action: 'render' };
163+
}
164+
return { state: current, action: 'launch' };
165+
}
166+
if (key === 'up' || key === 'k') {
167+
return {
168+
state: {
169+
...current,
170+
focusIndex: (current.focusIndex - 1 + definitions.length) % definitions.length,
171+
},
172+
action: 'render',
173+
};
174+
}
175+
if (key === 'down' || key === 'j') {
176+
return {
177+
state: {
178+
...current,
179+
focusIndex: (current.focusIndex + 1) % definitions.length,
180+
},
181+
action: 'render',
182+
};
183+
}
184+
185+
const codexCount = current.counts.codex || 0;
186+
const selectedCount = selectedCountFromPanelState(current);
187+
if (key === '+') {
188+
if (selectedCount >= current.maxSelected) {
189+
return { state: { ...current, message: `Selected agent count cannot exceed ${current.maxSelected}.` }, action: 'render' };
190+
}
191+
return { state: withCount(current, 'codex', codexCount + 1), action: 'render' };
192+
}
193+
if (key === '-') {
194+
return { state: withCount(current, 'codex', Math.max(0, codexCount - 1)), action: 'render' };
195+
}
196+
if (key === ' ' || key === 'space') {
197+
const agent = focusedAgent(current);
198+
const nextCount = current.counts[agent.id] > 0 ? 0 : 1;
199+
if (nextCount > 0 && selectedCount >= current.maxSelected) {
200+
return { state: { ...current, message: `Selected agent count cannot exceed ${current.maxSelected}.` }, action: 'render' };
201+
}
202+
return { state: withCount(current, agent.id, nextCount), action: 'render' };
203+
}
204+
205+
return { state: current, action: 'render' };
206+
}
207+
75208
function padLine(value, width) {
76209
const text = String(value || '');
77210
if (text.length >= width) return text.slice(0, width);
@@ -107,26 +240,44 @@ function renderAgentSelectionPanel(options = {}) {
107240
const count = countForAgent(selections, agent.id);
108241
const marker = count > 0 ? '●' : '○';
109242
const suffix = count > 1 ? ` x${count}` : '';
110-
return `${marker} ${agent.label} ${agent.shortLabel.toLowerCase()}${suffix}`;
243+
const focus = options.focusedAgentId === agent.id ? '› ' : '';
244+
return `${focus}${marker} ${agent.label} ${agent.shortLabel.toLowerCase()}${suffix}`;
111245
}),
112246
'',
113247
'Settings',
114248
`task: ${options.task || '-'}`,
115249
`base: ${options.base || 'current branch'}`,
116250
`Codex accounts: ${codexAccounts}`,
117251
`claims: ${claims}`,
252+
options.message ? `status: ${options.message}` : null,
118253
'',
119254
'↑/↓ navigate · Space toggle · +/- Codex accounts · Enter launch · ESC cancel',
120-
];
255+
].filter((row) => row !== null);
121256
return `${framePanel('Select Agent(s)', rows)}\n`;
122257
}
123258

259+
function renderInteractiveAgentSelectionPanel(state = {}) {
260+
return renderAgentSelectionPanel({
261+
task: state.task,
262+
base: state.base,
263+
claims: state.claims,
264+
maxSelected: state.maxSelected,
265+
selections: selectionsFromPanelState(state),
266+
focusedAgentId: focusedAgent(state)?.id,
267+
message: state.message,
268+
});
269+
}
270+
124271
module.exports = {
272+
applyAgentSelectionKey,
273+
createAgentSelectionPanelState,
125274
DEFAULT_MAX_SELECTED_AGENTS,
126275
countForAgent,
127276
framePanel,
128277
normalizeAgentSelections,
129278
parseAgentSelectionSpec,
279+
renderInteractiveAgentSelectionPanel,
130280
renderAgentSelectionPanel,
281+
selectionsFromPanelState,
131282
selectedAgentCount,
132283
};

0 commit comments

Comments
 (0)