Skip to content

Commit 5c91647

Browse files
NagyViktNagyViktclaude
authored
Turn the new-agent panel into a dmux-style input prompt (#528)
Phase 5 of the dmux-style cockpit plan: replace the placeholder new-agent panel with a real prompt modal. Typing prints to a buffered input field, Backspace trims, Enter submits an agent:start intent that now carries the typed task alongside the default agent and base, and Esc cancels back to main. The input handler runs above the global n/t/l/s/? shortcuts so typing letters lands in the buffer instead of re-routing to other modes. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1623ca3 commit 5c91647

5 files changed

Lines changed: 234 additions & 6 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# dmux-style cockpit — Phase 5: new-agent prompt overlay
2+
3+
## Why
4+
5+
Phase 1 wired the `[n]ew agent` hotkey, phase 2 advertised it on the
6+
welcome screen, but the actual `new-agent` panel was a static info
7+
block. Phase 5 turns it into a real input modal that captures a
8+
prompt for the agent and emits a structured `agent:start` intent the
9+
host shell can act on.
10+
11+
## What changes
12+
13+
- Replace `renderNewAgentPanel` with a dmux-style modal:
14+
- Heading `+ New Pane - <project>`
15+
- Project / Agent / Base info rows
16+
- Bordered input box with `> <typed-buffer>_` cursor
17+
- Footer hints `Enter to submit · Backspace to edit · Esc to cancel`
18+
- Track `state.newAgentInput` for the typed buffer.
19+
- In `new-agent` mode:
20+
- Printable ASCII (codes 0x20-0x7e) appends to the buffer.
21+
- Backspace (`\x7f` or `\b`) trims the last char.
22+
- `Enter` builds an `agent:start` intent that now carries the typed
23+
`task` field, then clears the buffer and returns to `main`.
24+
- `Esc` returns to `main` and leaves the buffer alone (cleared on
25+
next entry).
26+
- Extend `buildIntent('agent:start')` to include `task` from the
27+
buffer alongside the existing `agent` / `base` fields.
28+
29+
## Impact
30+
31+
- Input handler runs BEFORE the global `n`/`t`/`l`/`s`/`?` shortcuts so
32+
typing letters lands in the prompt rather than re-opening the same
33+
mode or jumping to a different one.
34+
- `Path` (`node:path`) is now imported by `control.js` for the
35+
project-name label in the heading.
36+
- ASCII-only renderer; no unicode glyphs.
37+
- No safety-model change: branches, worktrees, locks, PR-only finish
38+
flow are untouched.
39+
- Host wiring of the `agent:start` intent (spawning the actual `gx
40+
agents start "<task>"`) is left to the existing action runner /
41+
cockpit shell — phase 5 is strictly the cockpit-side UX.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
## ADDED Requirements
2+
3+
### Requirement: New-agent mode captures a prompt buffer
4+
The cockpit `new-agent` mode SHALL maintain `state.newAgentInput` as
5+
a string buffer that grows when the user types printable ASCII
6+
characters and shrinks by one character on backspace.
7+
8+
#### Scenario: Printable characters append to the buffer
9+
- **WHEN** the cockpit is in `new-agent` mode and the user types
10+
`h`, `i`, ` `, `!`
11+
- **THEN** `state.newAgentInput` is `'hi !'`
12+
- **AND** the cockpit remains in `new-agent` mode (no global shortcut
13+
hijacks the keystroke).
14+
15+
#### Scenario: Backspace trims the last character
16+
- **WHEN** the cockpit is in `new-agent` mode with
17+
`newAgentInput === 'abc'` and the user presses backspace
18+
- **THEN** `state.newAgentInput` becomes `'ab'`.
19+
20+
### Requirement: Enter on new-agent emits an enriched agent:start intent
21+
The cockpit key handler SHALL respond to `Enter` in `new-agent` mode
22+
by emitting `lastIntent = { type: 'agent:start', agent, base, task }`
23+
where `task` is the trimmed `newAgentInput`, then clearing the buffer
24+
and returning to `main` mode.
25+
26+
#### Scenario: Enter submits the typed task
27+
- **WHEN** the cockpit is in `new-agent` mode with
28+
`newAgentInput === 'fix auth'` and the user presses `Enter`
29+
- **THEN** the resulting state has `mode === 'main'`,
30+
`newAgentInput === ''`, and `lastIntent` equals
31+
`{ type: 'agent:start', agent: <default>, base: <default>, task:
32+
'fix auth' }`.
33+
34+
#### Scenario: Esc cancels without emitting an intent
35+
- **WHEN** the cockpit is in `new-agent` mode and the user presses
36+
`Esc`
37+
- **THEN** the resulting state has `mode === 'main'` and
38+
`lastIntent === null`.
39+
40+
### Requirement: New-agent panel renders the dmux-style prompt modal
41+
The cockpit `new-agent` mode panel SHALL render a heading containing
42+
`+ New Pane -` followed by the project name, project / agent / base
43+
rows, a bordered input box containing `> <buffer>_`, and a footer
44+
listing `Enter to submit`, `Backspace to edit`, and `Esc to cancel`.
45+
46+
#### Scenario: Panel shows heading, input box, and footer
47+
- **WHEN** the cockpit is in `new-agent` mode with `repoPath ===
48+
'/repo/gitguardex'` and `newAgentInput === 'refresh status'`
49+
- **THEN** the rendered panel contains `+ New Pane - gitguardex`
50+
- **AND** contains `| > refresh status_`
51+
- **AND** the footer contains `Enter to submit` and `Esc to cancel`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
- [x] 1.1 Capture proposal in `proposal.md`
5+
- [x] 1.2 Capture spec delta in `specs/cockpit-new-agent/spec.md`
6+
7+
## 2. Tests
8+
- [x] 2.1 Add `test/cockpit-new-agent.test.js` covering printable
9+
char append, backspace trim, Enter emits `agent:start` with
10+
`task`, Esc cancels, and the rendered prompt panel.
11+
12+
## 3. Implementation
13+
- [x] 3.1 Add `path` import to `src/cockpit/control.js`.
14+
- [x] 3.2 Extend `buildIntent('agent:start')` to include `task` from
15+
`state.newAgentInput`.
16+
- [x] 3.3 Update Enter handler in `new-agent` mode to clear
17+
`newAgentInput` and emit the enriched intent.
18+
- [x] 3.4 Add new-agent input handler ABOVE the global
19+
`n`/`t`/`l`/`s`/`?` shortcuts so typing letters lands in the
20+
buffer instead of re-routing.
21+
- [x] 3.5 Replace placeholder `renderNewAgentPanel` with a dmux-style
22+
modal (heading, project row, agent/base, input box with
23+
cursor, footer hints).
24+
25+
## 4. Cleanup
26+
- [ ] 4.1 Commit changes on the agent branch.
27+
- [ ] 4.2 Push branch and open a PR.
28+
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
29+
- [ ] 4.4 Record PR URL and `MERGED` evidence.

src/cockpit/control.js

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
const path = require('node:path');
34
const { readCockpitState } = require('./state');
45
const { renderSidebar } = require('./sidebar');
56
const { renderSettingsScreen } = require('./settings-render');
@@ -241,6 +242,7 @@ function buildIntent(state, kind) {
241242
type: 'agent:start',
242243
agent: current.settings.defaultAgent,
243244
base: current.settings.defaultBase,
245+
task: text(current.newAgentInput || ''),
244246
};
245247
}
246248
if (kind === 'terminal:open') {
@@ -436,6 +438,20 @@ function applyKey(state, rawKey) {
436438
lastIntent: null,
437439
});
438440
}
441+
if (mode === 'new-agent') {
442+
const raw = typeof rawKey === 'string' ? rawKey : (rawKey && rawKey.sequence) || '';
443+
if (raw === '' || raw === '\b') {
444+
const next = (current.newAgentInput || '').slice(0, -1);
445+
return normalizeControlState({ ...current, newAgentInput: next, lastIntent: null });
446+
}
447+
if (typeof raw === 'string' && raw.length === 1) {
448+
const code = raw.charCodeAt(0);
449+
if (code >= 0x20 && code <= 0x7e) {
450+
const next = `${current.newAgentInput || ''}${raw}`;
451+
return normalizeControlState({ ...current, newAgentInput: next, lastIntent: null });
452+
}
453+
}
454+
}
439455
if (key === 'n') {
440456
return openActionRow(current, 'new-agent');
441457
}
@@ -468,10 +484,12 @@ function applyKey(state, rawKey) {
468484
});
469485
}
470486
if (mode === 'new-agent') {
487+
const intent = buildIntent(current, 'agent:start');
471488
return normalizeControlState({
472489
...current,
473490
mode: 'main',
474-
lastIntent: buildIntent(current, 'agent:start'),
491+
newAgentInput: '',
492+
lastIntent: intent,
475493
});
476494
}
477495
if (mode === 'terminal') {
@@ -699,14 +717,24 @@ function renderShortcutsPanel() {
699717

700718
function renderNewAgentPanel(state) {
701719
const current = normalizeControlState(state);
720+
const input = text(current.newAgentInput || '');
721+
const repoLabel = current.repoPath ? path.basename(current.repoPath) : 'project';
722+
const inputBox = `+${'-'.repeat(64)}+`;
723+
const inputRow = `| > ${input}_${' '.repeat(Math.max(60 - input.length, 0))} |`;
702724
return [
703-
'new agent',
725+
`+ New Pane - ${repoLabel}`,
704726
'',
705-
`agent: ${current.settings.defaultAgent}`,
706-
`base: ${current.settings.defaultBase}`,
727+
`Project: ${repoLabel} (${current.repoPath || '-'})`,
728+
`Agent: ${current.settings.defaultAgent}`,
729+
`Base: ${current.settings.defaultBase}`,
707730
'',
708-
'Enter: open a guarded agent lane in Kitty',
709-
'Esc: back to main',
731+
'Enter a prompt for your AI agent.',
732+
'',
733+
inputBox,
734+
inputRow,
735+
inputBox,
736+
'',
737+
'Enter to submit · Backspace to edit · Esc to cancel',
710738
'',
711739
].join('\n');
712740
}

test/cockpit-new-agent.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
3+
const assert = require('node:assert/strict');
4+
const test = require('node:test');
5+
6+
const { applyCockpitAction, renderControlFrame } = require('../src/cockpit/control');
7+
8+
function snapshot(sessions = []) {
9+
return { repoPath: '/repo/gitguardex', baseBranch: 'main', sessions };
10+
}
11+
12+
test('typing printable characters in new-agent mode appends to the input buffer', () => {
13+
let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() });
14+
state = applyCockpitAction(state, { type: 'key', key: 'n' });
15+
assert.equal(state.mode, 'new-agent');
16+
assert.equal(state.newAgentInput || '', '');
17+
18+
state = applyCockpitAction(state, { type: 'key', key: 'h' });
19+
state = applyCockpitAction(state, { type: 'key', key: 'i' });
20+
state = applyCockpitAction(state, { type: 'key', key: ' ' });
21+
state = applyCockpitAction(state, { type: 'key', key: '!' });
22+
assert.equal(state.newAgentInput, 'hi !');
23+
assert.equal(state.mode, 'new-agent', 'staying in new-agent mode while typing');
24+
});
25+
26+
test('backspace trims the last character of the new-agent input', () => {
27+
let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() });
28+
state = applyCockpitAction(state, { type: 'key', key: 'n' });
29+
for (const ch of 'abc') {
30+
state = applyCockpitAction(state, { type: 'key', key: ch });
31+
}
32+
assert.equal(state.newAgentInput, 'abc');
33+
state = applyCockpitAction(state, { type: 'key', key: '' });
34+
assert.equal(state.newAgentInput, 'ab');
35+
state = applyCockpitAction(state, { type: 'key', key: '\b' });
36+
assert.equal(state.newAgentInput, 'a');
37+
});
38+
39+
test('Enter on new-agent emits agent:start with the typed task and clears input', () => {
40+
let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() });
41+
state = applyCockpitAction(state, { type: 'key', key: 'n' });
42+
for (const ch of 'fix auth') {
43+
state = applyCockpitAction(state, { type: 'key', key: ch });
44+
}
45+
state = applyCockpitAction(state, { type: 'key', key: 'enter' });
46+
assert.equal(state.mode, 'main');
47+
assert.equal(state.newAgentInput, '');
48+
assert.equal(state.lastIntent && state.lastIntent.type, 'agent:start');
49+
assert.equal(state.lastIntent.task, 'fix auth');
50+
assert.ok(state.lastIntent.agent);
51+
assert.ok(state.lastIntent.base);
52+
});
53+
54+
test('Esc on new-agent returns to main without emitting an intent', () => {
55+
let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() });
56+
state = applyCockpitAction(state, { type: 'key', key: 'n' });
57+
state = applyCockpitAction(state, { type: 'key', key: 'x' });
58+
state = applyCockpitAction(state, { type: 'key', key: '' });
59+
assert.equal(state.mode, 'main');
60+
assert.equal(state.lastIntent, null);
61+
});
62+
63+
test('renderNewAgentPanel renders the prompt box and footer hints', () => {
64+
const seeded = {
65+
mode: 'new-agent',
66+
repoPath: '/repo/gitguardex',
67+
sessions: [],
68+
newAgentInput: 'refresh status',
69+
settings: { defaultAgent: 'codex', defaultBase: 'main' },
70+
};
71+
const frame = renderControlFrame(seeded).replace(/\x1b\[[0-9;]*m/g, '');
72+
assert.match(frame, /\+ New Pane - gitguardex/);
73+
assert.match(frame, /Project:\s+gitguardex/);
74+
assert.match(frame, /Agent:\s+codex/);
75+
assert.match(frame, /Base:\s+main/);
76+
assert.match(frame, /\| > refresh status_/);
77+
assert.match(frame, /Enter to submit/);
78+
assert.match(frame, /Esc to cancel/);
79+
});

0 commit comments

Comments
 (0)