From ae07761e4697410aca0e0d4308a249c06c92860d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 09:47:36 +0200 Subject: [PATCH] Turn the new-agent panel into a dmux-style input prompt 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: Claude Opus 4.7 (1M context) --- .../proposal.md | 41 ++++++++++ .../specs/cockpit-new-agent/spec.md | 51 ++++++++++++ .../tasks.md | 29 +++++++ src/cockpit/control.js | 40 ++++++++-- test/cockpit-new-agent.test.js | 79 +++++++++++++++++++ 5 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/proposal.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/specs/cockpit-new-agent/spec.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/tasks.md create mode 100644 test/cockpit-new-agent.test.js diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/proposal.md new file mode 100644 index 0000000..6a763ff --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/proposal.md @@ -0,0 +1,41 @@ +# dmux-style cockpit — Phase 5: new-agent prompt overlay + +## Why + +Phase 1 wired the `[n]ew agent` hotkey, phase 2 advertised it on the +welcome screen, but the actual `new-agent` panel was a static info +block. Phase 5 turns it into a real input modal that captures a +prompt for the agent and emits a structured `agent:start` intent the +host shell can act on. + +## What changes + +- Replace `renderNewAgentPanel` with a dmux-style modal: + - Heading `+ New Pane - ` + - Project / Agent / Base info rows + - Bordered input box with `> _` cursor + - Footer hints `Enter to submit · Backspace to edit · Esc to cancel` +- Track `state.newAgentInput` for the typed buffer. +- In `new-agent` mode: + - Printable ASCII (codes 0x20-0x7e) appends to the buffer. + - Backspace (`\x7f` or `\b`) trims the last char. + - `Enter` builds an `agent:start` intent that now carries the typed + `task` field, then clears the buffer and returns to `main`. + - `Esc` returns to `main` and leaves the buffer alone (cleared on + next entry). +- Extend `buildIntent('agent:start')` to include `task` from the + buffer alongside the existing `agent` / `base` fields. + +## Impact + +- Input handler runs BEFORE the global `n`/`t`/`l`/`s`/`?` shortcuts so + typing letters lands in the prompt rather than re-opening the same + mode or jumping to a different one. +- `Path` (`node:path`) is now imported by `control.js` for the + project-name label in the heading. +- ASCII-only renderer; no unicode glyphs. +- No safety-model change: branches, worktrees, locks, PR-only finish + flow are untouched. +- Host wiring of the `agent:start` intent (spawning the actual `gx + agents start ""`) is left to the existing action runner / + cockpit shell — phase 5 is strictly the cockpit-side UX. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/specs/cockpit-new-agent/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/specs/cockpit-new-agent/spec.md new file mode 100644 index 0000000..9390ba7 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/specs/cockpit-new-agent/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: New-agent mode captures a prompt buffer +The cockpit `new-agent` mode SHALL maintain `state.newAgentInput` as +a string buffer that grows when the user types printable ASCII +characters and shrinks by one character on backspace. + +#### Scenario: Printable characters append to the buffer +- **WHEN** the cockpit is in `new-agent` mode and the user types + `h`, `i`, ` `, `!` +- **THEN** `state.newAgentInput` is `'hi !'` +- **AND** the cockpit remains in `new-agent` mode (no global shortcut + hijacks the keystroke). + +#### Scenario: Backspace trims the last character +- **WHEN** the cockpit is in `new-agent` mode with + `newAgentInput === 'abc'` and the user presses backspace +- **THEN** `state.newAgentInput` becomes `'ab'`. + +### Requirement: Enter on new-agent emits an enriched agent:start intent +The cockpit key handler SHALL respond to `Enter` in `new-agent` mode +by emitting `lastIntent = { type: 'agent:start', agent, base, task }` +where `task` is the trimmed `newAgentInput`, then clearing the buffer +and returning to `main` mode. + +#### Scenario: Enter submits the typed task +- **WHEN** the cockpit is in `new-agent` mode with + `newAgentInput === 'fix auth'` and the user presses `Enter` +- **THEN** the resulting state has `mode === 'main'`, + `newAgentInput === ''`, and `lastIntent` equals + `{ type: 'agent:start', agent: , base: , task: + 'fix auth' }`. + +#### Scenario: Esc cancels without emitting an intent +- **WHEN** the cockpit is in `new-agent` mode and the user presses + `Esc` +- **THEN** the resulting state has `mode === 'main'` and + `lastIntent === null`. + +### Requirement: New-agent panel renders the dmux-style prompt modal +The cockpit `new-agent` mode panel SHALL render a heading containing +`+ New Pane -` followed by the project name, project / agent / base +rows, a bordered input box containing `> _`, and a footer +listing `Enter to submit`, `Backspace to edit`, and `Esc to cancel`. + +#### Scenario: Panel shows heading, input box, and footer +- **WHEN** the cockpit is in `new-agent` mode with `repoPath === + '/repo/gitguardex'` and `newAgentInput === 'refresh status'` +- **THEN** the rendered panel contains `+ New Pane - gitguardex` +- **AND** contains `| > refresh status_` +- **AND** the footer contains `Enter to submit` and `Esc to cancel`. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/tasks.md new file mode 100644 index 0000000..50fd26e --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase5-new-agent-2026-05-05-09-35/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-new-agent/spec.md` + +## 2. Tests +- [x] 2.1 Add `test/cockpit-new-agent.test.js` covering printable + char append, backspace trim, Enter emits `agent:start` with + `task`, Esc cancels, and the rendered prompt panel. + +## 3. Implementation +- [x] 3.1 Add `path` import to `src/cockpit/control.js`. +- [x] 3.2 Extend `buildIntent('agent:start')` to include `task` from + `state.newAgentInput`. +- [x] 3.3 Update Enter handler in `new-agent` mode to clear + `newAgentInput` and emit the enriched intent. +- [x] 3.4 Add new-agent input handler ABOVE the global + `n`/`t`/`l`/`s`/`?` shortcuts so typing letters lands in the + buffer instead of re-routing. +- [x] 3.5 Replace placeholder `renderNewAgentPanel` with a dmux-style + modal (heading, project row, agent/base, input box with + cursor, footer hints). + +## 4. Cleanup +- [ ] 4.1 Commit changes on the agent branch. +- [ ] 4.2 Push branch and open a PR. +- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`. +- [ ] 4.4 Record PR URL and `MERGED` evidence. diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 7836e71..9a52006 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -1,5 +1,6 @@ 'use strict'; +const path = require('node:path'); const { readCockpitState } = require('./state'); const { renderSidebar } = require('./sidebar'); const { renderSettingsScreen } = require('./settings-render'); @@ -241,6 +242,7 @@ function buildIntent(state, kind) { type: 'agent:start', agent: current.settings.defaultAgent, base: current.settings.defaultBase, + task: text(current.newAgentInput || ''), }; } if (kind === 'terminal:open') { @@ -436,6 +438,20 @@ function applyKey(state, rawKey) { lastIntent: null, }); } + if (mode === 'new-agent') { + const raw = typeof rawKey === 'string' ? rawKey : (rawKey && rawKey.sequence) || ''; + if (raw === '' || raw === '\b') { + const next = (current.newAgentInput || '').slice(0, -1); + return normalizeControlState({ ...current, newAgentInput: next, lastIntent: null }); + } + if (typeof raw === 'string' && raw.length === 1) { + const code = raw.charCodeAt(0); + if (code >= 0x20 && code <= 0x7e) { + const next = `${current.newAgentInput || ''}${raw}`; + return normalizeControlState({ ...current, newAgentInput: next, lastIntent: null }); + } + } + } if (key === 'n') { return openActionRow(current, 'new-agent'); } @@ -468,10 +484,12 @@ function applyKey(state, rawKey) { }); } if (mode === 'new-agent') { + const intent = buildIntent(current, 'agent:start'); return normalizeControlState({ ...current, mode: 'main', - lastIntent: buildIntent(current, 'agent:start'), + newAgentInput: '', + lastIntent: intent, }); } if (mode === 'terminal') { @@ -699,14 +717,24 @@ function renderShortcutsPanel() { function renderNewAgentPanel(state) { const current = normalizeControlState(state); + const input = text(current.newAgentInput || ''); + const repoLabel = current.repoPath ? path.basename(current.repoPath) : 'project'; + const inputBox = `+${'-'.repeat(64)}+`; + const inputRow = `| > ${input}_${' '.repeat(Math.max(60 - input.length, 0))} |`; return [ - 'new agent', + `+ New Pane - ${repoLabel}`, '', - `agent: ${current.settings.defaultAgent}`, - `base: ${current.settings.defaultBase}`, + `Project: ${repoLabel} (${current.repoPath || '-'})`, + `Agent: ${current.settings.defaultAgent}`, + `Base: ${current.settings.defaultBase}`, '', - 'Enter: open a guarded agent lane in Kitty', - 'Esc: back to main', + 'Enter a prompt for your AI agent.', + '', + inputBox, + inputRow, + inputBox, + '', + 'Enter to submit · Backspace to edit · Esc to cancel', '', ].join('\n'); } diff --git a/test/cockpit-new-agent.test.js b/test/cockpit-new-agent.test.js new file mode 100644 index 0000000..393632a --- /dev/null +++ b/test/cockpit-new-agent.test.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { applyCockpitAction, renderControlFrame } = require('../src/cockpit/control'); + +function snapshot(sessions = []) { + return { repoPath: '/repo/gitguardex', baseBranch: 'main', sessions }; +} + +test('typing printable characters in new-agent mode appends to the input buffer', () => { + let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() }); + state = applyCockpitAction(state, { type: 'key', key: 'n' }); + assert.equal(state.mode, 'new-agent'); + assert.equal(state.newAgentInput || '', ''); + + state = applyCockpitAction(state, { type: 'key', key: 'h' }); + state = applyCockpitAction(state, { type: 'key', key: 'i' }); + state = applyCockpitAction(state, { type: 'key', key: ' ' }); + state = applyCockpitAction(state, { type: 'key', key: '!' }); + assert.equal(state.newAgentInput, 'hi !'); + assert.equal(state.mode, 'new-agent', 'staying in new-agent mode while typing'); +}); + +test('backspace trims the last character of the new-agent input', () => { + let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() }); + state = applyCockpitAction(state, { type: 'key', key: 'n' }); + for (const ch of 'abc') { + state = applyCockpitAction(state, { type: 'key', key: ch }); + } + assert.equal(state.newAgentInput, 'abc'); + state = applyCockpitAction(state, { type: 'key', key: '' }); + assert.equal(state.newAgentInput, 'ab'); + state = applyCockpitAction(state, { type: 'key', key: '\b' }); + assert.equal(state.newAgentInput, 'a'); +}); + +test('Enter on new-agent emits agent:start with the typed task and clears input', () => { + let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() }); + state = applyCockpitAction(state, { type: 'key', key: 'n' }); + for (const ch of 'fix auth') { + state = applyCockpitAction(state, { type: 'key', key: ch }); + } + state = applyCockpitAction(state, { type: 'key', key: 'enter' }); + assert.equal(state.mode, 'main'); + assert.equal(state.newAgentInput, ''); + assert.equal(state.lastIntent && state.lastIntent.type, 'agent:start'); + assert.equal(state.lastIntent.task, 'fix auth'); + assert.ok(state.lastIntent.agent); + assert.ok(state.lastIntent.base); +}); + +test('Esc on new-agent returns to main without emitting an intent', () => { + let state = applyCockpitAction({}, { type: 'refresh', cockpitState: snapshot() }); + state = applyCockpitAction(state, { type: 'key', key: 'n' }); + state = applyCockpitAction(state, { type: 'key', key: 'x' }); + state = applyCockpitAction(state, { type: 'key', key: '' }); + assert.equal(state.mode, 'main'); + assert.equal(state.lastIntent, null); +}); + +test('renderNewAgentPanel renders the prompt box and footer hints', () => { + const seeded = { + mode: 'new-agent', + repoPath: '/repo/gitguardex', + sessions: [], + newAgentInput: 'refresh status', + settings: { defaultAgent: 'codex', defaultBase: 'main' }, + }; + const frame = renderControlFrame(seeded).replace(/\x1b\[[0-9;]*m/g, ''); + assert.match(frame, /\+ New Pane - gitguardex/); + assert.match(frame, /Project:\s+gitguardex/); + assert.match(frame, /Agent:\s+codex/); + assert.match(frame, /Base:\s+main/); + assert.match(frame, /\| > refresh status_/); + assert.match(frame, /Enter to submit/); + assert.match(frame, /Esc to cancel/); +});