Skip to content

Commit a8ec97c

Browse files
NagyViktNagyVikt
andauthored
Make the agent launcher feel like a terminal cockpit (#491)
The existing panel was functionally interactive but still looked like a compact form. This change gives gx agents start --panel the dmux-style shell the operator expects: blue GitGuardex rail, matrix field, centered welcome card, and keyboard-first new-agent affordance. Constraint: Preserve existing branch planning, dry-run output, and session behavior. Rejected: Replace gx default status with the launcher | too broad for this panel-focused request. Confidence: high Scope-risk: narrow Directive: Keep branch/worktree/session logic out of the renderer; this surface is presentation and key handling only. Tested: node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js test/agents-start.test.js test/cli-args-dispatch.test.js test/agents-start-claims.test.js Tested: node --check src/agents/selection-panel.js && node --check src/agents/start.js Tested: openspec validate agent-codex-blue-dmux-gx-agent-launcher-2026-04-30-11-18 --type change --strict Tested: node bin/multiagent-safety.js agents --target /home/deadpool/Documents/recodee/gitguardex start 'fix auth tests' --panel --codex-accounts 3 --base main --dry-run Not-tested: Real interactive terminal screenshot capture; verified through TTY-render unit coverage and dry-run smoke output. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 199aab2 commit a8ec97c

8 files changed

Lines changed: 289 additions & 6 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-blue-dmux-gx-agent-launcher-2026-04-30-11-18
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Blue dmux gx agent launcher
2+
3+
## Why
4+
5+
`gx agents start --panel` already supports interactive selection, but its visual shell is still a compact form. Operators expect the launcher to open like dmux: a full terminal surface with a left project rail, a matrix-style main field, a centered brand card, and keyboard-first create flow.
6+
7+
## What Changes
8+
9+
- Render the agent start panel as a dmux-style full-terminal GitGuardex shell.
10+
- Use a blue/cyan terminal palette for the TTY surface.
11+
- Preserve existing dry-run, multi-account planning, and keyboard selection behavior.
12+
- Add `[n]` as a launch alias so the welcome prompt matches the dmux-style "new agent" affordance.
13+
14+
## Impact
15+
16+
The change is isolated to the `gx agents start --panel` renderer/controller and focused tests. It does not alter branch creation, lock claims, sessions, finish flow, or non-panel agent startup.
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 uses blue dmux-style terminal shell
4+
5+
`gx agents start <task> --panel` SHALL render a full-terminal GitGuardex launcher shell with a blue/cyan visual style, a left project rail, a matrix-style main field, and a centered GitGuardex welcome card.
6+
7+
#### Scenario: scripted panel output includes dmux shell
8+
9+
- **WHEN** `gx agents start "fix auth tests" --panel --codex-accounts 3 --dry-run` runs without a TTY
10+
- **THEN** the output SHALL include a left `gitguardex` rail
11+
- **AND** the output SHALL include a `Welcome` main field
12+
- **AND** the output SHALL include `Press [n] or Enter to create a new agent`
13+
- **AND** the command SHALL keep printing dry-run plans as before.
14+
15+
#### Scenario: TTY panel uses blue ANSI styling
16+
17+
- **WHEN** an operator runs `gx agents start "fix auth tests" --panel --codex-accounts 1 --dry-run` in a TTY
18+
- **THEN** the interactive panel SHALL render with blue/cyan ANSI styling
19+
- **AND** it SHALL preserve keyboard controls for navigation, toggling, Codex account count, launch, and cancel.
20+
21+
#### Scenario: operator launches with new-agent shortcut
22+
23+
- **WHEN** an operator presses `n` in the interactive panel
24+
- **THEN** the command SHALL launch the selected agent plan the same way Enter does.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
5+
- [x] 1.1 Capture dmux-style blue launcher behavior.
6+
7+
## 2. Tests
8+
9+
- [x] 2.1 Cover dmux shell rendering and blue ANSI output.
10+
- [x] 2.2 Cover `[n]` launch key alias.
11+
- [x] 2.3 Run focused launcher tests.
12+
13+
## 3. Implementation
14+
15+
- [x] 3.1 Replace compact default panel with dmux-style terminal shell.
16+
- [x] 3.2 Pass TTY dimensions/color into the interactive renderer.
17+
- [x] 3.3 Preserve compact renderer as an explicit compatibility path.
18+
19+
## 4. Verification
20+
21+
- [x] 4.1 Run focused Node tests.
22+
- Evidence: `node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js test/agents-start.test.js test/cli-args-dispatch.test.js test/agents-start-claims.test.js` passed (`30/30`).
23+
- [x] 4.2 Validate OpenSpec change.
24+
- Evidence: `openspec validate agent-codex-blue-dmux-gx-agent-launcher-2026-04-30-11-18 --type change --strict` passed.
25+
- [x] 4.3 Smoke `gx agents start "fix auth tests" --panel --codex-accounts 3 --base main --dry-run`.
26+
- Evidence: `node bin/multiagent-safety.js agents --target /home/deadpool/Documents/recodee/gitguardex start "fix auth tests" --panel --codex-accounts 3 --base main --dry-run` rendered the blue dmux-style GitGuardex shell and planned three dry-run lanes without creating branches/worktrees.
27+
28+
## 5. Cleanup
29+
30+
- [ ] 5.1 Run the finish pipeline: `gx branch finish --branch agent/codex/blue-dmux-gx-agent-launcher-2026-04-30-11-18 --base main --via-pr --wait-for-merge --cleanup`.
31+
- [ ] 5.2 Record PR URL, final `MERGED` state, and sandbox cleanup evidence.

src/agents/selection-panel.js

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ const {
66
} = require('./registry');
77

88
const DEFAULT_MAX_SELECTED_AGENTS = 10;
9+
const DEFAULT_PANEL_WIDTH = 118;
10+
const DEFAULT_PANEL_HEIGHT = 30;
11+
const SIDEBAR_WIDTH = 36;
12+
13+
const ANSI = {
14+
reset: '\x1b[0m',
15+
blue: '\x1b[34m',
16+
brightBlue: '\x1b[94m',
17+
cyan: '\x1b[36m',
18+
inverse: '\x1b[7m',
19+
};
920

1021
function parsePositiveInteger(value, flagName) {
1122
const parsed = Number.parseInt(String(value || ''), 10);
@@ -157,7 +168,7 @@ function applyAgentSelectionKey(state = {}, rawKey) {
157168
if (key === 'ctrl-c' || key === 'escape' || key === 'q') {
158169
return { state: current, action: 'cancel' };
159170
}
160-
if (key === 'enter') {
171+
if (key === 'enter' || key === 'n') {
161172
if (selectedCountFromPanelState(current) <= 0) {
162173
return { state: { ...current, message: 'Select at least one agent before launch.' }, action: 'render' };
163174
}
@@ -211,6 +222,31 @@ function padLine(value, width) {
211222
return `${text}${' '.repeat(width - text.length)}`;
212223
}
213224

225+
function centerLine(value, width) {
226+
const text = String(value || '');
227+
if (text.length >= width) return text.slice(0, width);
228+
const left = Math.floor((width - text.length) / 2);
229+
return `${' '.repeat(left)}${text}${' '.repeat(width - text.length - left)}`;
230+
}
231+
232+
function colorize(value, color, options = {}) {
233+
if (!options.color) return value;
234+
const code = ANSI[color];
235+
return code ? `${code}${value}${ANSI.reset}` : value;
236+
}
237+
238+
function panelWidth(options = {}) {
239+
const width = Number(options.width);
240+
if (!Number.isFinite(width)) return DEFAULT_PANEL_WIDTH;
241+
return Math.max(80, Math.floor(width));
242+
}
243+
244+
function panelHeight(options = {}) {
245+
const height = Number(options.height);
246+
if (!Number.isFinite(height)) return DEFAULT_PANEL_HEIGHT;
247+
return Math.max(24, Math.floor(height) - 1);
248+
}
249+
214250
function framePanel(title, rows, width = 92) {
215251
const safeWidth = Math.max(40, width);
216252
const titleText = ` ${title} `;
@@ -224,7 +260,158 @@ function framePanel(title, rows, width = 92) {
224260
].join('\n');
225261
}
226262

263+
function matrixChar(row, column, seed) {
264+
const value = (row * 17 + column * 31 + seed * 13 + row * column) % 41;
265+
if (value === 0 || value === 7) return '1';
266+
if (value === 3 || value === 11) return '0';
267+
return '.';
268+
}
269+
270+
function renderMatrixLine(width, row, seed) {
271+
let line = '';
272+
for (let column = 0; column < width; column += 1) {
273+
line += matrixChar(row, column, seed);
274+
}
275+
return line;
276+
}
277+
278+
function overlay(line, segment, column) {
279+
const start = Math.max(0, Math.min(column, line.length));
280+
const text = String(segment || '').slice(0, Math.max(0, line.length - start));
281+
return `${line.slice(0, start)}${text}${line.slice(start + text.length)}`;
282+
}
283+
284+
function renderSidebarRows(options, selections, definitions, width, height) {
285+
const total = selectedAgentCount(selections);
286+
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
287+
const codexAccounts = countForAgent(selections, 'codex');
288+
const claims = Array.isArray(options.claims) && options.claims.length > 0
289+
? options.claims.join(', ')
290+
: 'none';
291+
const topBars = '█'.repeat(Math.max(0, width - 13));
292+
const rows = [
293+
`─ gx ${'─'.repeat(Math.max(0, width - 5))}`,
294+
`▦ gitguardex ${topBars}`.slice(0, width),
295+
' [n]ew agent [t]erminal',
296+
'',
297+
' Select Agent(s)',
298+
` Selected: ${total}/${maxSelected}`,
299+
'',
300+
...definitions.map((agent) => {
301+
const count = countForAgent(selections, agent.id);
302+
const marker = count > 0 ? '●' : '○';
303+
const suffix = count > 1 ? ` x${count}` : '';
304+
const focus = options.focusedAgentId === agent.id ? '› ' : ' ';
305+
return `${focus}${marker} ${agent.label} ${agent.shortLabel.toLowerCase()}${suffix}`;
306+
}),
307+
'',
308+
' Settings',
309+
` task: ${options.task || '-'}`,
310+
` base: ${options.base || 'current branch'}`,
311+
` Codex accounts: ${codexAccounts}`,
312+
` claims: ${claims}`,
313+
options.message ? ` status: ${options.message}` : '',
314+
];
315+
316+
while (rows.length < height - 5) {
317+
rows.push('');
318+
}
319+
320+
rows.push(
321+
'─'.repeat(width),
322+
' [l]ogs • [p]rojects',
323+
' Press [?] for keyboard shortcuts',
324+
' Tip: Hidden panes keep running.',
325+
'',
326+
);
327+
328+
return rows.slice(0, height).map((row) => padLine(row, width));
329+
}
330+
331+
function renderLogoCardRows(options, selections, width) {
332+
const total = selectedAgentCount(selections);
333+
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
334+
const innerWidth = Math.max(46, width - 2);
335+
const logoRows = [
336+
' ██████╗ ██╗ ██╗',
337+
'██╔════╝ ╚██╗██╔╝',
338+
'██║ ███╗ ╚███╔╝ ',
339+
'██║ ██║ ██╔██╗ ',
340+
'╚██████╔╝██╔╝ ██╗',
341+
' ╚═════╝ ╚═╝ ╚═╝',
342+
];
343+
const body = [
344+
...logoRows.map((line) => centerLine(line, innerWidth)),
345+
centerLine('gitguardex', innerWidth),
346+
centerLine('AI developer agent guardrail multiplexer', innerWidth),
347+
centerLine(`Select Agent(s) · Selected: ${total}/${maxSelected}`, innerWidth),
348+
centerLine('Press [n] or Enter to create a new agent', innerWidth),
349+
];
350+
351+
return [
352+
`┌${'─'.repeat(innerWidth)}┐`,
353+
...body.map((row) => `│${row}│`),
354+
`└${'─'.repeat(innerWidth)}┘`,
355+
];
356+
}
357+
358+
function renderMainRows(options, selections, width, height) {
359+
const seed = String(options.task || 'gitguardex')
360+
.split('')
361+
.reduce((sum, char) => sum + char.charCodeAt(0), 0);
362+
const title = ' Welcome ';
363+
const rows = [
364+
`─${title}${'─'.repeat(Math.max(0, width - title.length - 1))}`,
365+
];
366+
367+
for (let row = 1; row < height - 1; row += 1) {
368+
rows.push(renderMatrixLine(width, row, seed));
369+
}
370+
rows.push('─'.repeat(width));
371+
372+
const cardWidth = Math.min(Math.max(56, Math.floor(width * 0.45)), width - 6);
373+
const cardRows = renderLogoCardRows(options, selections, cardWidth);
374+
const top = Math.max(2, Math.floor((height - cardRows.length) / 2));
375+
const left = Math.max(2, Math.floor((width - cardWidth) / 2));
376+
cardRows.forEach((cardRow, offset) => {
377+
const target = top + offset;
378+
if (target >= 0 && target < rows.length) {
379+
rows[target] = overlay(rows[target], cardRow, left);
380+
}
381+
});
382+
383+
return rows.map((row) => padLine(row, width));
384+
}
385+
386+
function renderDmuxAgentSelectionPanel(options = {}) {
387+
const definitions = getAgentDefinitions();
388+
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
389+
const selections = options.selections || normalizeAgentSelections({ ...options, maxSelected });
390+
const width = panelWidth(options);
391+
const height = panelHeight(options);
392+
const sidebarWidth = Math.min(SIDEBAR_WIDTH, Math.max(28, Math.floor(width * 0.38)));
393+
const mainWidth = Math.max(42, width - sidebarWidth - 1);
394+
const sidebar = renderSidebarRows({ ...options, maxSelected }, selections, definitions, sidebarWidth, height);
395+
const main = renderMainRows({ ...options, maxSelected }, selections, mainWidth, height);
396+
const keyLine = padLine(' ↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel ', width);
397+
const lines = [];
398+
399+
for (let index = 0; index < height; index += 1) {
400+
const left = colorize(sidebar[index] || ''.padEnd(sidebarWidth, ' '), 'cyan', options);
401+
const divider = colorize('│', 'blue', options);
402+
const right = colorize(main[index] || ''.padEnd(mainWidth, ' '), 'brightBlue', options);
403+
lines.push(`${left}${divider}${right}`);
404+
}
405+
406+
lines.push(colorize(keyLine, 'inverse', options));
407+
return `${lines.join('\n')}\n`;
408+
}
409+
227410
function renderAgentSelectionPanel(options = {}) {
411+
if (!options.compact) {
412+
return renderDmuxAgentSelectionPanel(options);
413+
}
414+
228415
const definitions = getAgentDefinitions();
229416
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
230417
const selections = options.selections || normalizeAgentSelections({ ...options, maxSelected });
@@ -251,12 +438,12 @@ function renderAgentSelectionPanel(options = {}) {
251438
`claims: ${claims}`,
252439
options.message ? `status: ${options.message}` : null,
253440
'',
254-
'↑/↓ navigate · Space toggle · +/- Codex accounts · Enter launch · ESC cancel',
441+
'↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel',
255442
].filter((row) => row !== null);
256443
return `${framePanel('Select Agent(s)', rows)}\n`;
257444
}
258445

259-
function renderInteractiveAgentSelectionPanel(state = {}) {
446+
function renderInteractiveAgentSelectionPanel(state = {}, options = {}) {
260447
return renderAgentSelectionPanel({
261448
task: state.task,
262449
base: state.base,
@@ -265,6 +452,7 @@ function renderInteractiveAgentSelectionPanel(state = {}) {
265452
selections: selectionsFromPanelState(state),
266453
focusedAgentId: focusedAgent(state)?.id,
267454
message: state.message,
455+
...options,
268456
});
269457
}
270458

src/agents/start.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,11 @@ function startInteractiveAgentPanel(repoRoot, options, deps = {}) {
238238
if (stdout && stdout.isTTY) {
239239
writeStream(stdout, '\x1b[?25l\x1b[H\x1b[2J\x1b[3J');
240240
}
241-
writeStream(stdout, renderInteractiveAgentSelectionPanel(state));
241+
writeStream(stdout, renderInteractiveAgentSelectionPanel(state, {
242+
color: Boolean(stdout && stdout.isTTY),
243+
width: stdout && stdout.columns,
244+
height: stdout && stdout.rows,
245+
}));
242246
}
243247

244248
function finish(result) {

test/agents-selection-panel.test.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ test('normalizeAgentSelections merges repeated agents and enforces the max', ()
3535
);
3636
});
3737

38-
test('renderAgentSelectionPanel shows selected count and codex account setting', () => {
38+
test('renderAgentSelectionPanel shows a dmux-style GitGuardex shell', () => {
3939
const output = renderAgentSelectionPanel({
4040
task: 'repair auth',
4141
base: 'main',
@@ -44,12 +44,23 @@ test('renderAgentSelectionPanel shows selected count and codex account setting',
4444
});
4545

4646
assert.match(output, /Select Agent\(s\)/);
47+
assert.match(output, /Welcome/);
48+
assert.match(output, /gitguardex/);
49+
assert.match(output, /\[n\]ew agent/);
4750
assert.match(output, /Selected: 3\/10/);
4851
assert.match(output, / Codex cx x3/);
4952
assert.match(output, /Codex accounts: 3/);
5053
assert.match(output, /task: repair auth/);
5154
assert.match(output, /base: main/);
5255
assert.match(output, /claims: src\/auth\.js/);
56+
57+
const blueOutput = renderAgentSelectionPanel({
58+
task: 'repair auth',
59+
agentSelectionSpecs: ['codex:1'],
60+
color: true,
61+
});
62+
assert.match(blueOutput, /\x1b\[36m/);
63+
assert.match(blueOutput, /\x1b\[94m/);
5364
});
5465

5566
test('interactive panel keys move focus, toggle agents, and adjust codex accounts', () => {
@@ -73,6 +84,7 @@ test('interactive panel keys move focus, toggle agents, and adjust codex account
7384

7485
state = applyAgentSelectionKey(state, '-').state;
7586
assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 2);
87+
assert.equal(applyAgentSelectionKey(state, 'n').action, 'launch');
7688
assert.equal(applyAgentSelectionKey(state, '\r').action, 'launch');
7789
assert.equal(applyAgentSelectionKey(state, '\u001b').action, 'cancel');
7890
});

0 commit comments

Comments
 (0)