Skip to content

Commit 5d425d8

Browse files
NagyViktNagyViktclaude
authored
Route cockpit intents into pane actions for terminal and agent flows (#530)
Phase 6 of the dmux-style cockpit plan: alias the cockpit's terminal:open and agent:start intent types to the existing runAddTerminal and runAddAgent handlers in PANE_ACTION_HANDLERS, ship a COCKPIT_INTENT_ALIASES map for external dispatchers, and add a dispatchCockpitIntent(intent, context) helper that merges intent fields into the dispatch context and routes through dispatchPaneAction. This closes the gap between the cockpit's structured lastIntent output and the action runner, so pressing t actually spawns a kitty pane and pressing Enter on the new-agent prompt drives the safe agent-start workflow. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7277056 commit 5d425d8

5 files changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# dmux-style cockpit — Phase 6: terminal pane action wiring
2+
3+
## Why
4+
5+
Phases 1-5 wired the dmux-style hotkeys, branded the welcome screen,
6+
shipped the project picker, the logs viewer, and the new-agent prompt.
7+
Each of those overlays emits a structured `lastIntent` (e.g.
8+
`terminal:open`, `agent:start`, `project:switch`), but there was no
9+
direct routing from those intents into the action runner — callers
10+
had to translate intent types to action IDs themselves.
11+
12+
Phase 6 closes that gap so the cockpit's `[t]erminal` overlay actually
13+
spawns a terminal pane (via `launchTerminalPane` on whichever
14+
terminal backend the cockpit is using), and so `[n]ew agent` Enter
15+
events land in `runAddAgent`.
16+
17+
## What changes
18+
19+
- Add aliases in `PANE_ACTION_HANDLERS` so `dispatchPaneAction` can
20+
route `'terminal:open'` to `runAddTerminal` and `'agent:start'` to
21+
`runAddAgent` directly, with no caller-side translation.
22+
- Add a `COCKPIT_INTENT_ALIASES` map (`terminal:open → add-terminal`,
23+
`agent:start → add-agent`) so external dispatchers can normalize
24+
intent types if they prefer the action-ID surface.
25+
- Add a `dispatchCockpitIntent(intent, context)` helper that takes
26+
the cockpit's `lastIntent` and dispatches it through
27+
`dispatchPaneAction`, merging any session/branch/worktree fields
28+
from the intent into the action context.
29+
- Tests cover the alias routing, the helper, the missing-session
30+
fall-back to repoRoot, and the agent:start forwarding.
31+
32+
## Impact
33+
34+
- `runAddTerminal` already calls `backend.launchTerminalPane`. Aliasing
35+
`terminal:open` to it means cockpit's `t` overlay produces a real
36+
Kitty pane spawn (when the kitty backend is selected) without any
37+
changes to `runAddTerminal`'s implementation.
38+
- `runAddAgent` already wraps the safe `gx agents start` workflow.
39+
Aliasing `agent:start` to it lets the cockpit `n` overlay drive the
40+
same flow directly.
41+
- No behavior change to safety model, branches, worktrees, locks, or
42+
PR-only finish flow.
43+
- Host wiring (calling `dispatchCockpitIntent` from
44+
`startCockpitControl`'s key loop) is left for a follow-up; this
45+
phase delivers the routing surface and tests.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Cockpit pane action dispatcher accepts intent aliases
4+
The cockpit pane action dispatcher SHALL recognize the cockpit intent
5+
types `terminal:open` and `agent:start` as direct action IDs that
6+
route to `runAddTerminal` and `runAddAgent` respectively, in addition
7+
to the existing `add-terminal` and `add-agent` action IDs.
8+
9+
#### Scenario: terminal:open routes to launchTerminalPane
10+
- **WHEN** `dispatchPaneAction('terminal:open', { runtime: { terminalBackend } })`
11+
is called against a backend that exposes `launchTerminalPane`
12+
- **THEN** `launchTerminalPane` is invoked exactly once
13+
- **AND** the returned result has `ok === true`.
14+
15+
#### Scenario: agent:start routes to runAddAgent
16+
- **WHEN** `dispatchPaneAction('agent:start', { startAgentLane, runtime, ... })`
17+
is called with a `worktreePath` in the context
18+
- **THEN** the provided `startAgentLane` hook is invoked once with the
19+
forwarded `task`, `agent`, `base`, and `worktreePath` fields.
20+
21+
### Requirement: Cockpit ships a dispatchCockpitIntent helper
22+
The cockpit module SHALL export a `dispatchCockpitIntent(intent,
23+
context)` helper that takes a structured cockpit intent (the
24+
`lastIntent` produced by control state transitions) and routes it
25+
through `dispatchPaneAction`, merging the intent fields into the
26+
dispatch context.
27+
28+
#### Scenario: dispatchCockpitIntent merges intent into context
29+
- **WHEN** `dispatchCockpitIntent({ type: 'terminal:open', sessionId,
30+
branch, worktreePath }, { runtime: { terminalBackend } })` is called
31+
- **THEN** the dispatched action context contains the intent's
32+
`sessionId`, `branch`, and `worktreePath`
33+
- **AND** `launchTerminalPane` is invoked with `actionId === 'add-terminal'`
34+
and the merged worktree path.
35+
36+
#### Scenario: dispatchCockpitIntent rejects empty intents
37+
- **WHEN** `dispatchCockpitIntent(null, ...)` or
38+
`dispatchCockpitIntent({}, ...)` is called
39+
- **THEN** the result has `ok === false` and the message contains
40+
`No cockpit intent`.
41+
42+
### Requirement: Cockpit exposes COCKPIT_INTENT_ALIASES for external dispatchers
43+
The cockpit module SHALL export a frozen `COCKPIT_INTENT_ALIASES` map
44+
from intent types (`terminal:open`, `agent:start`) to action IDs
45+
(`add-terminal`, `add-agent`) so external dispatchers can normalize
46+
intent types if they prefer the action-ID surface.
47+
48+
#### Scenario: Aliases map intent types to action IDs
49+
- **WHEN** the cockpit module is required
50+
- **THEN** `COCKPIT_INTENT_ALIASES['terminal:open']` equals
51+
`'add-terminal'` and `COCKPIT_INTENT_ALIASES['agent:start']` equals
52+
`'add-agent'`.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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-terminal-action/spec.md`
6+
7+
## 2. Tests
8+
- [x] 2.1 Add `test/cockpit-terminal-action.test.js` covering the
9+
alias routing for `terminal:open`/`agent:start`, the
10+
`dispatchCockpitIntent` helper, the missing-session
11+
fall-back, and the empty-intent failure path.
12+
13+
## 3. Implementation
14+
- [x] 3.1 Add `'terminal:open'` and `'agent:start'` aliases to
15+
`PANE_ACTION_HANDLERS` in `src/cockpit/pane-actions.js`.
16+
- [x] 3.2 Add `COCKPIT_INTENT_ALIASES` mapping intent types to action
17+
IDs.
18+
- [x] 3.3 Add `dispatchCockpitIntent(intent, context)` helper that
19+
merges intent fields into the dispatch context and routes
20+
through `dispatchPaneAction`.
21+
- [x] 3.4 Export `COCKPIT_INTENT_ALIASES` and `dispatchCockpitIntent`
22+
from the module so external callers can use them.
23+
24+
## 4. Cleanup
25+
- [ ] 4.1 Commit changes on the agent branch.
26+
- [ ] 4.2 Push branch and open a PR.
27+
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
28+
- [ ] 4.4 Record PR URL and `MERGED` evidence.

src/cockpit/pane-actions.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,33 @@ const PANE_ACTION_HANDLERS = Object.freeze({
537537
'add-terminal': runAddTerminal,
538538
'add-agent': runAddAgent,
539539
'reopen-closed-worktree': () => statusMessage('Reopen Closed Worktree', 'No closed worktree was restored.'),
540+
'terminal:open': runAddTerminal,
541+
'agent:start': runAddAgent,
540542
});
541543

544+
const COCKPIT_INTENT_ALIASES = Object.freeze({
545+
'terminal:open': 'add-terminal',
546+
'agent:start': 'add-agent',
547+
});
548+
549+
function dispatchCockpitIntent(intent, context = {}) {
550+
if (!intent || typeof intent !== 'object' || !intent.type) {
551+
return resultShape({ ok: false, message: 'No cockpit intent to dispatch.' });
552+
}
553+
const aliased = COCKPIT_INTENT_ALIASES[intent.type] || intent.type;
554+
const merged = {
555+
...context,
556+
...intent,
557+
sessionId: intent.sessionId || context.sessionId,
558+
branch: intent.branch || context.branch,
559+
worktreePath: intent.worktreePath || context.worktreePath,
560+
task: intent.task || context.task,
561+
agent: intent.agent || context.agent,
562+
base: intent.base || context.base,
563+
};
564+
return dispatchPaneAction(aliased, merged);
565+
}
566+
542567
function dispatchPaneAction(action, context = {}) {
543568
const normalized = normalizeAction(action);
544569
const handler = PANE_ACTION_HANDLERS[normalized];
@@ -560,7 +585,9 @@ function dispatchPaneAction(action, context = {}) {
560585
}
561586

562587
module.exports = {
588+
COCKPIT_INTENT_ALIASES,
563589
PANE_ACTION_HANDLERS,
590+
dispatchCockpitIntent,
564591
dispatchPaneAction,
565592
normalizeAction,
566593
operationContext,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use strict';
2+
3+
const assert = require('node:assert/strict');
4+
const test = require('node:test');
5+
6+
const {
7+
COCKPIT_INTENT_ALIASES,
8+
dispatchCockpitIntent,
9+
dispatchPaneAction,
10+
} = require('../src/cockpit/pane-actions');
11+
12+
function fakeBackend() {
13+
const calls = [];
14+
return {
15+
name: 'kitty',
16+
launchTerminalPane(payload) {
17+
calls.push({ method: 'launchTerminalPane', payload });
18+
return { ok: true, message: 'spawned terminal pane' };
19+
},
20+
calls,
21+
};
22+
}
23+
24+
test('terminal:open intent routes to runAddTerminal which calls launchTerminalPane', () => {
25+
const backend = fakeBackend();
26+
const result = dispatchCockpitIntent(
27+
{ type: 'terminal:open', sessionId: 's1', branch: 'agent/codex/s1', worktreePath: '/repo/.omx/s1' },
28+
{
29+
runtime: { terminalBackend: backend },
30+
repoRoot: '/repo/gitguardex',
31+
},
32+
);
33+
assert.equal(result.ok, true);
34+
assert.equal(backend.calls.length, 1);
35+
assert.equal(backend.calls[0].method, 'launchTerminalPane');
36+
assert.equal(backend.calls[0].payload.actionId, 'add-terminal');
37+
assert.equal(backend.calls[0].payload.worktreePath, '/repo/.omx/s1');
38+
});
39+
40+
test('terminal:open intent without a session falls back to repoRoot for cwd', () => {
41+
const backend = fakeBackend();
42+
const result = dispatchCockpitIntent(
43+
{ type: 'terminal:open' },
44+
{
45+
runtime: { terminalBackend: backend },
46+
repoRoot: '/repo/gitguardex',
47+
},
48+
);
49+
assert.equal(result.ok, true);
50+
assert.equal(backend.calls[0].payload.repoRoot, '/repo/gitguardex');
51+
});
52+
53+
test('PANE_ACTION_HANDLERS exposes terminal:open as an alias for add-terminal', () => {
54+
const backend = fakeBackend();
55+
const aliasResult = dispatchPaneAction('terminal:open', {
56+
runtime: { terminalBackend: backend },
57+
repoRoot: '/repo/gitguardex',
58+
});
59+
assert.equal(aliasResult.ok, true);
60+
assert.equal(backend.calls.length, 1);
61+
assert.equal(backend.calls[0].method, 'launchTerminalPane');
62+
});
63+
64+
test('agent:start intent routes to runAddAgent and forwards task/agent/base', () => {
65+
const calls = [];
66+
const result = dispatchCockpitIntent(
67+
{
68+
type: 'agent:start',
69+
task: 'fix auth',
70+
agent: 'codex',
71+
base: 'main',
72+
worktreePath: '/repo/.omx/active',
73+
branch: 'agent/codex/active',
74+
},
75+
{
76+
runtime: {
77+
terminalBackend: fakeBackend(),
78+
},
79+
startAgentLane(request) {
80+
calls.push(request);
81+
return { ok: true, message: 'started agent lane' };
82+
},
83+
repoRoot: '/repo/gitguardex',
84+
},
85+
);
86+
assert.equal(result.ok, true);
87+
assert.equal(calls.length, 1);
88+
assert.equal(calls[0].task, 'fix auth');
89+
assert.equal(calls[0].agent, 'codex');
90+
assert.equal(calls[0].base, 'main');
91+
assert.equal(calls[0].worktreePath, '/repo/.omx/active');
92+
});
93+
94+
test('dispatchCockpitIntent with no intent returns a structured failure', () => {
95+
const result = dispatchCockpitIntent(null, {});
96+
assert.equal(result.ok, false);
97+
assert.match(result.message, /No cockpit intent/i);
98+
});
99+
100+
test('COCKPIT_INTENT_ALIASES maps intent types to action ids', () => {
101+
assert.equal(COCKPIT_INTENT_ALIASES['terminal:open'], 'add-terminal');
102+
assert.equal(COCKPIT_INTENT_ALIASES['agent:start'], 'add-agent');
103+
});

0 commit comments

Comments
 (0)