Skip to content

Commit e4b45db

Browse files
NagyViktNagyVikt
andauthored
Make the gx launcher explain pane control (#495)
The zero-argument launcher is now the first pane users see, so it needs to teach the same dmux/cockpit actions instead of spending the main area on decoration. Constraint: Existing agent-selection and dry-run launch behavior must stay stable. Rejected: Implement terminal-pane creation inside agents start | terminal pane runtime already belongs to gx cockpit and would widen this UI pass into session orchestration. Confidence: high Scope-risk: narrow Directive: Keep cockpit-only actions as guidance here until terminal/project pane creation is wired through the cockpit runtime. Tested: node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js Tested: node --check src/agents/selection-panel.js Tested: openspec validate agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02 --type change --strict Tested: openspec validate --specs Tested: git diff --check Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 2eb64a6 commit e4b45db

7 files changed

Lines changed: 191 additions & 86 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-30
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## Why
2+
3+
- The `gx` zero-argument launcher opens a terminal panel, but the right side was decorative and the visible `[t]erminal` / pane-management cues did not explain how they map to the existing dmux cockpit controls.
4+
- Users need the launcher home to behave like a compact pane-management command surface: clear task entry, clear selected agent state, and visible dmux pane shortcuts without moving focus to a separate sidebar first.
5+
6+
## What Changes
7+
8+
- Replace the decorative launcher body with a bounded Pane Management shortcut map modeled on the existing `gx cockpit`/dmux commands.
9+
- Clarify task-input mode versus launch/action mode so typing a task is not confused with terminal pane shortcuts.
10+
- Add reducer coverage for `?`, terminal guidance, and `Alt+Shift+M` pane-menu guidance.
11+
12+
## Impact
13+
14+
- Affects only the terminal launcher render/reducer and focused tests.
15+
- Does not create new terminal-pane runtime behavior; terminal and pane-management actions point users to the existing `gx cockpit` surface.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Launcher Home Pane Management Guidance
4+
The interactive `gx` launcher home SHALL present a bounded pane-management shortcut map that mirrors the existing `gx cockpit`/dmux actions while preserving the agent selection workflow.
5+
6+
#### Scenario: Empty launcher home
7+
- **WHEN** `gx agents start --panel --dry-run` is rendered without a task
8+
- **THEN** the panel shows task-entry guidance
9+
- **AND** the panel shows pane-management shortcuts including terminal, files, and `Alt+Shift+M` pane menu guidance
10+
- **AND** no branch or worktree plan is emitted before a task exists.
11+
12+
#### Scenario: Shortcut help while entering a task
13+
- **WHEN** the launcher is in task-entry mode
14+
- **AND** the user presses `?`
15+
- **THEN** the task text remains unchanged
16+
- **AND** the launcher reports that the shortcut map is visible on the right.
17+
18+
#### Scenario: Cockpit-only pane actions
19+
- **WHEN** the launcher is in agent-selection mode
20+
- **AND** the user presses a cockpit-only pane action such as `t` or `Alt+Shift+M`
21+
- **THEN** the launcher keeps the current selection state
22+
- **AND** reports guidance that the action is available from `gx cockpit`.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## Definition of Done
2+
3+
This change is complete only when **all** of the following are true:
4+
5+
- Every checkbox below is checked.
6+
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
7+
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.
8+
9+
## Handoff
10+
11+
- Handoff: change=`agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02`; branch=`agent/codex/improve-gx-launcher-pane-management-ux-2026-04-30-12-02`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
12+
- Copy prompt: Continue `agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02` on branch `agent/codex/improve-gx-launcher-pane-management-ux-2026-04-30-12-02`. Work inside the existing sandbox, review `openspec/changes/agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/improve-gx-launcher-pane-management-ux-2026-04-30-12-02 --base main --via-pr --wait-for-merge --cleanup`.
13+
14+
## 1. Specification
15+
16+
- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02`.
17+
- [x] 1.2 Define normative requirements in `specs/agents-interactive-launcher/spec.md`.
18+
19+
## 2. Implementation
20+
21+
- [x] 2.1 Implement scoped behavior changes.
22+
- [x] 2.2 Add/update focused regression coverage.
23+
24+
## 3. Verification
25+
26+
- [x] 3.1 Run targeted project verification commands.
27+
- Evidence: `node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js` passed (`12/12`).
28+
- Evidence: `git diff --check` passed.
29+
- [x] 3.2 Run `openspec validate agent-codex-improve-gx-launcher-pane-management-ux-2026-04-30-12-02 --type change --strict`.
30+
- Evidence: change is valid.
31+
- [x] 3.3 Run `openspec validate --specs`.
32+
- Evidence: command completed with `No items found to validate.`
33+
34+
## 4. Cleanup (mandatory; run before claiming completion)
35+
36+
- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/improve-gx-launcher-pane-management-ux-2026-04-30-12-02 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
37+
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
38+
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).

src/agents/selection-panel.js

Lines changed: 89 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,41 @@ const DEFAULT_MAX_SELECTED_AGENTS = 10;
99
const DEFAULT_PANEL_WIDTH = 118;
1010
const DEFAULT_PANEL_HEIGHT = 30;
1111
const SIDEBAR_WIDTH = 36;
12+
const PANEL_ACTIONS = [
13+
['n', 'New agent', 'create an agent pane in this repo'],
14+
['t', 'Terminal', 'open a shell pane from gx cockpit'],
15+
['p', 'Project', 'create pane in another project'],
16+
['Alt+Shift+M', 'Pane menu', 'act on the focused tmux pane'],
17+
['j/k', 'Jump', 'move between panes in the list'],
18+
['m', 'Menu', 'open pane context actions'],
19+
['x', 'Close', 'close selected pane'],
20+
['b', 'Child worktree', 'branch from selected pane'],
21+
['f', 'Files', 'browse selected worktree read-only'],
22+
['h/H', 'Hide panes', 'hide one or isolate selected pane'],
23+
['P', 'Project focus', 'show only one project'],
24+
['a/A', 'Add to pane', 'agent or terminal in worktree'],
25+
['r', 'Reopen', 'restore a closed worktree'],
26+
];
27+
28+
const PANEL_SHORTCUT_MESSAGES = {
29+
'?': 'Shortcut map is shown on the right.',
30+
t: 'Terminal panes are managed in gx cockpit; open cockpit, then press t.',
31+
p: 'Project panes are managed in gx cockpit; open cockpit, then press p.',
32+
m: 'Pane menu is available in gx cockpit with m or Alt+Shift+M.',
33+
'alt-shift-m': 'Pane menu is available in gx cockpit for the focused tmux pane.',
34+
x: 'Close is available from gx cockpit pane menu.',
35+
b: 'Child worktrees are available from gx cockpit pane menu.',
36+
f: 'File browser is available from gx cockpit pane menu.',
37+
h: 'Hide/show panes from gx cockpit pane menu.',
38+
H: 'Hide/show panes from gx cockpit pane menu.',
39+
P: 'Project focus is available from gx cockpit pane menu.',
40+
a: 'Add agent to worktree from gx cockpit pane menu.',
41+
A: 'Add terminal to worktree from gx cockpit pane menu.',
42+
r: 'Reopen closed worktrees from gx cockpit pane menu.',
43+
s: 'Settings are available from gx cockpit.',
44+
l: 'Logs are available from gx cockpit.',
45+
L: 'Layout reset is available from gx cockpit.',
46+
};
1247

1348
const ANSI = {
1449
reset: '\x1b[0m',
@@ -157,12 +192,14 @@ function normalizePanelKey(value) {
157192
const raw = rawPanelKey(value);
158193
if (raw === '\u0003') return 'ctrl-c';
159194
if (raw === '\u0015') return 'ctrl-u';
195+
if (raw === '\u001bM') return 'alt-shift-m';
160196
if (raw === '\u001b') return 'escape';
161197
if (raw === '\r' || raw === '\n') return 'enter';
162198
if (raw === '\u007f' || raw === '\b') return 'backspace';
163199
if (raw === '\u001b[A') return 'up';
164200
if (raw === '\u001b[B') return 'down';
165-
return raw.toLowerCase();
201+
if (raw.length === 1 && raw !== raw.toUpperCase()) return raw.toLowerCase();
202+
return raw;
166203
}
167204

168205
function printableTaskInput(value) {
@@ -214,6 +251,15 @@ function applyAgentSelectionKey(state = {}, rawKey) {
214251
}
215252

216253
if (current.taskInputActive) {
254+
if (key === '?') {
255+
return {
256+
state: {
257+
...current,
258+
message: PANEL_SHORTCUT_MESSAGES['?'],
259+
},
260+
action: 'render',
261+
};
262+
}
217263
if (key === 'enter') {
218264
return taskLaunchState(current);
219265
}
@@ -249,6 +295,9 @@ function applyAgentSelectionKey(state = {}, rawKey) {
249295
if (key === 'enter' || key === 'n') {
250296
return taskLaunchState(current);
251297
}
298+
if (PANEL_SHORTCUT_MESSAGES[key]) {
299+
return { state: { ...current, message: PANEL_SHORTCUT_MESSAGES[key] }, action: 'render' };
300+
}
252301
if (key === 'up' || key === 'k') {
253302
return {
254303
state: {
@@ -297,13 +346,6 @@ function padLine(value, width) {
297346
return `${text}${' '.repeat(width - text.length)}`;
298347
}
299348

300-
function centerLine(value, width) {
301-
const text = String(value || '');
302-
if (text.length >= width) return text.slice(0, width);
303-
const left = Math.floor((width - text.length) / 2);
304-
return `${' '.repeat(left)}${text}${' '.repeat(width - text.length - left)}`;
305-
}
306-
307349
function colorize(value, color, options = {}) {
308350
if (!options.color) return value;
309351
const code = ANSI[color];
@@ -335,27 +377,6 @@ function framePanel(title, rows, width = 92) {
335377
].join('\n');
336378
}
337379

338-
function matrixChar(row, column, seed) {
339-
const value = (row * 17 + column * 31 + seed * 13 + row * column) % 41;
340-
if (value === 0 || value === 7) return '1';
341-
if (value === 3 || value === 11) return '0';
342-
return '.';
343-
}
344-
345-
function renderMatrixLine(width, row, seed) {
346-
let line = '';
347-
for (let column = 0; column < width; column += 1) {
348-
line += matrixChar(row, column, seed);
349-
}
350-
return line;
351-
}
352-
353-
function overlay(line, segment, column) {
354-
const start = Math.max(0, Math.min(column, line.length));
355-
const text = String(segment || '').slice(0, Math.max(0, line.length - start));
356-
return `${line.slice(0, start)}${text}${line.slice(start + text.length)}`;
357-
}
358-
359380
function renderSidebarRows(options, selections, definitions, width, height) {
360381
const total = selectedAgentCount(selections);
361382
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
@@ -368,7 +389,9 @@ function renderSidebarRows(options, selections, definitions, width, height) {
368389
const rows = [
369390
`─ gx ${'─'.repeat(Math.max(0, width - 5))}`,
370391
`▦ gitguardex ${topBars}`.slice(0, width),
371-
' [n]ew agent [t]erminal',
392+
options.taskInputActive
393+
? ' Type task [?]help Esc cancel'
394+
: ' [n] launch [t] terminal [?] help',
372395
'',
373396
' Select Agent(s)',
374397
` Selected: ${total}/${maxSelected}`,
@@ -388,7 +411,7 @@ function renderSidebarRows(options, selections, definitions, width, height) {
388411
` claims: ${claims}`,
389412
options.message
390413
? ` status: ${options.message}`
391-
: (options.taskInputActive ? ' status: Type task, then Enter.' : ''),
414+
: (options.taskInputActive ? ' status: Type task, then Enter.' : ' status: Enter launches selected agent.'),
392415
];
393416

394417
while (rows.length < height - 5) {
@@ -397,70 +420,55 @@ function renderSidebarRows(options, selections, definitions, width, height) {
397420

398421
rows.push(
399422
'─'.repeat(width),
400-
' [l]ogs [p]rojects',
423+
' [l]ogs [p]rojects [s]ettings',
401424
' Press [?] for keyboard shortcuts',
402-
' Tip: Hidden panes keep running.',
425+
' Tip: live panes: gx cockpit',
403426
'',
404427
);
405428

406429
return rows.slice(0, height).map((row) => padLine(row, width));
407430
}
408431

409-
function renderLogoCardRows(options, selections, width) {
432+
function boundedText(value, width) {
433+
const text = String(value || '');
434+
if (text.length <= width) return text;
435+
if (width <= 3) return text.slice(0, width);
436+
return `${text.slice(0, width - 3)}...`;
437+
}
438+
439+
function renderPaneManagementRows(options, selections, width, height) {
410440
const total = selectedAgentCount(selections);
411441
const maxSelected = options.maxSelected || DEFAULT_MAX_SELECTED_AGENTS;
412-
const innerWidth = Math.max(46, width - 2);
413-
const logoRows = [
414-
' ██████╗ ██╗ ██╗',
415-
'██╔════╝ ╚██╗██╔╝',
416-
'██║ ███╗ ╚███╔╝ ',
417-
'██║ ██║ ██╔██╗ ',
418-
'╚██████╔╝██╔╝ ██╗',
419-
' ╚═════╝ ╚═╝ ╚═╝',
420-
];
421-
const body = [
422-
...logoRows.map((line) => centerLine(line, innerWidth)),
423-
centerLine('gitguardex', innerWidth),
424-
centerLine('AI developer agent guardrail multiplexer', innerWidth),
425-
centerLine(`Select Agent(s) · Selected: ${total}/${maxSelected}`, innerWidth),
426-
centerLine(options.taskInputActive
427-
? 'Type task, then press Enter'
428-
: 'Press [n] or Enter to create a new agent', innerWidth),
442+
const task = String(options.task || '').trim();
443+
const bodyWidth = Math.max(24, width - 2);
444+
const actionWidth = Math.min(17, Math.max(12, Math.floor(bodyWidth * 0.24)));
445+
const labelWidth = Math.min(18, Math.max(12, Math.floor(bodyWidth * 0.25)));
446+
const detailWidth = Math.max(12, bodyWidth - actionWidth - labelWidth - 4);
447+
const rows = [
448+
`─ Welcome / Pane Management ${'─'.repeat(Math.max(0, bodyWidth - 27))}`,
449+
`Selected ${total}/${maxSelected} · ${task ? `task ${boundedText(task, Math.max(8, bodyWidth - 22))}` : 'type a task to start'}`,
450+
'',
451+
`${padLine('Key', actionWidth)} ${padLine('Action', labelWidth)} Detail`,
452+
`${'─'.repeat(actionWidth)} ${'─'.repeat(labelWidth)} ${'─'.repeat(detailWidth)}`,
453+
...PANEL_ACTIONS.map(([key, label, detail]) => (
454+
`${padLine(key, actionWidth)} ${padLine(label, labelWidth)} ${boundedText(detail, detailWidth)}`
455+
)),
456+
'',
457+
'Navigation',
458+
'↑/↓ or j/k move agent focus · Space toggles selected agent',
459+
'+/- adjusts Codex account count · Enter launches new agent',
460+
'',
461+
'Text input',
462+
'Type task text directly · Backspace edits · Ctrl+U clears',
463+
'? keeps this shortcut map visible · Esc cancels',
429464
];
430465

431-
return [
432-
`┌${'─'.repeat(innerWidth)}┐`,
433-
...body.map((row) => `│${row}│`),
434-
`└${'─'.repeat(innerWidth)}┘`,
435-
];
466+
while (rows.length < height) rows.push('');
467+
return rows.slice(0, height).map((row) => padLine(row, width));
436468
}
437469

438470
function renderMainRows(options, selections, width, height) {
439-
const seed = String(options.task || 'gitguardex')
440-
.split('')
441-
.reduce((sum, char) => sum + char.charCodeAt(0), 0);
442-
const title = ' Welcome ';
443-
const rows = [
444-
`─${title}${'─'.repeat(Math.max(0, width - title.length - 1))}`,
445-
];
446-
447-
for (let row = 1; row < height - 1; row += 1) {
448-
rows.push(renderMatrixLine(width, row, seed));
449-
}
450-
rows.push('─'.repeat(width));
451-
452-
const cardWidth = Math.min(Math.max(56, Math.floor(width * 0.45)), width - 6);
453-
const cardRows = renderLogoCardRows(options, selections, cardWidth);
454-
const top = Math.max(2, Math.floor((height - cardRows.length) / 2));
455-
const left = Math.max(2, Math.floor((width - cardWidth) / 2));
456-
cardRows.forEach((cardRow, offset) => {
457-
const target = top + offset;
458-
if (target >= 0 && target < rows.length) {
459-
rows[target] = overlay(rows[target], cardRow, left);
460-
}
461-
});
462-
463-
return rows.map((row) => padLine(row, width));
471+
return renderPaneManagementRows(options, selections, width, height);
464472
}
465473

466474
function renderDmuxAgentSelectionPanel(options = {}) {

test/agents-selection-panel.test.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,12 @@ test('renderAgentSelectionPanel shows a dmux-style GitGuardex shell', () => {
4545

4646
assert.match(output, /Select Agent\(s\)/);
4747
assert.match(output, /Welcome/);
48+
assert.match(output, /Pane Management/);
4849
assert.match(output, /gitguardex/);
49-
assert.match(output, /\[n\]ew agent/);
50+
assert.match(output, /\[n\] launch/);
51+
assert.match(output, /\[t\] terminal/);
52+
assert.match(output, /Alt\+Shift\+M/);
53+
assert.match(output, /Files/);
5054
assert.match(output, /Selected: 3\/10/);
5155
assert.match(output, / Codex cx x3/);
5256
assert.match(output, /Codex accounts: 3/);
@@ -71,9 +75,15 @@ test('empty interactive panel captures a task before launch', () => {
7175
});
7276

7377
assert.equal(state.taskInputActive, true);
74-
assert.match(renderInteractiveAgentSelectionPanel(state), /Type task, then press Enter/);
78+
assert.match(renderInteractiveAgentSelectionPanel(state), /type a task to start/);
79+
assert.match(renderInteractiveAgentSelectionPanel(state), /Type task text directly/);
7580
assert.match(renderInteractiveAgentSelectionPanel(state), /task: _/);
7681

82+
let help = applyAgentSelectionKey(state, '?');
83+
assert.equal(help.action, 'render');
84+
assert.equal(help.state.task, '');
85+
assert.match(help.state.message, /Shortcut map is shown on the right/);
86+
7787
let next = applyAgentSelectionKey(state, 'n');
7888
assert.equal(next.action, 'render');
7989
assert.equal(next.state.task, 'n');
@@ -112,6 +122,12 @@ test('interactive panel keys move focus, toggle agents, and adjust codex account
112122

113123
state = applyAgentSelectionKey(state, '-').state;
114124
assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 2);
125+
const terminalHelp = applyAgentSelectionKey(state, 't');
126+
assert.equal(terminalHelp.action, 'render');
127+
assert.match(terminalHelp.state.message, /Terminal panes are managed in gx cockpit/);
128+
const paneMenuHelp = applyAgentSelectionKey(state, '\u001bM');
129+
assert.equal(paneMenuHelp.action, 'render');
130+
assert.match(paneMenuHelp.state.message, /Pane menu is available in gx cockpit/);
115131
assert.equal(applyAgentSelectionKey(state, 'n').action, 'launch');
116132
assert.equal(applyAgentSelectionKey(state, '\r').action, 'launch');
117133
assert.equal(applyAgentSelectionKey(state, '\u001b').action, 'cancel');

0 commit comments

Comments
 (0)