Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>`
- Project / Agent / Base info rows
- Bordered input box with `> <typed-buffer>_` 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 "<task>"`) is left to the existing action runner /
cockpit shell — phase 5 is strictly the cockpit-side UX.
Original file line number Diff line number Diff line change
@@ -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: <default>, base: <default>, 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 `> <buffer>_`, 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`.
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 34 additions & 6 deletions src/cockpit/control.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const path = require('node:path');
const { readCockpitState } = require('./state');
const { renderSidebar } = require('./sidebar');
const { renderSettingsScreen } = require('./settings-render');
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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');
}
Expand Down
79 changes: 79 additions & 0 deletions test/cockpit-new-agent.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading