From 39548027e8b044efc917a591cd28c424a67373c8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 09:53:25 +0200 Subject: [PATCH] Route cockpit intents into pane actions for terminal and agent flows 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: Claude Opus 4.7 (1M context) --- .../proposal.md | 45 ++++++++ .../specs/cockpit-terminal-action/spec.md | 52 +++++++++ .../tasks.md | 28 +++++ src/cockpit/pane-actions.js | 27 +++++ test/cockpit-terminal-action.test.js | 103 ++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/proposal.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/specs/cockpit-terminal-action/spec.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/tasks.md create mode 100644 test/cockpit-terminal-action.test.js diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/proposal.md new file mode 100644 index 0000000..6817e12 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/proposal.md @@ -0,0 +1,45 @@ +# dmux-style cockpit — Phase 6: terminal pane action wiring + +## Why + +Phases 1-5 wired the dmux-style hotkeys, branded the welcome screen, +shipped the project picker, the logs viewer, and the new-agent prompt. +Each of those overlays emits a structured `lastIntent` (e.g. +`terminal:open`, `agent:start`, `project:switch`), but there was no +direct routing from those intents into the action runner — callers +had to translate intent types to action IDs themselves. + +Phase 6 closes that gap so the cockpit's `[t]erminal` overlay actually +spawns a terminal pane (via `launchTerminalPane` on whichever +terminal backend the cockpit is using), and so `[n]ew agent` Enter +events land in `runAddAgent`. + +## What changes + +- Add aliases in `PANE_ACTION_HANDLERS` so `dispatchPaneAction` can + route `'terminal:open'` to `runAddTerminal` and `'agent:start'` to + `runAddAgent` directly, with no caller-side translation. +- Add a `COCKPIT_INTENT_ALIASES` map (`terminal:open → add-terminal`, + `agent:start → add-agent`) so external dispatchers can normalize + intent types if they prefer the action-ID surface. +- Add a `dispatchCockpitIntent(intent, context)` helper that takes + the cockpit's `lastIntent` and dispatches it through + `dispatchPaneAction`, merging any session/branch/worktree fields + from the intent into the action context. +- Tests cover the alias routing, the helper, the missing-session + fall-back to repoRoot, and the agent:start forwarding. + +## Impact + +- `runAddTerminal` already calls `backend.launchTerminalPane`. Aliasing + `terminal:open` to it means cockpit's `t` overlay produces a real + Kitty pane spawn (when the kitty backend is selected) without any + changes to `runAddTerminal`'s implementation. +- `runAddAgent` already wraps the safe `gx agents start` workflow. + Aliasing `agent:start` to it lets the cockpit `n` overlay drive the + same flow directly. +- No behavior change to safety model, branches, worktrees, locks, or + PR-only finish flow. +- Host wiring (calling `dispatchCockpitIntent` from + `startCockpitControl`'s key loop) is left for a follow-up; this + phase delivers the routing surface and tests. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/specs/cockpit-terminal-action/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/specs/cockpit-terminal-action/spec.md new file mode 100644 index 0000000..2dcbc6d --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/specs/cockpit-terminal-action/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Cockpit pane action dispatcher accepts intent aliases +The cockpit pane action dispatcher SHALL recognize the cockpit intent +types `terminal:open` and `agent:start` as direct action IDs that +route to `runAddTerminal` and `runAddAgent` respectively, in addition +to the existing `add-terminal` and `add-agent` action IDs. + +#### Scenario: terminal:open routes to launchTerminalPane +- **WHEN** `dispatchPaneAction('terminal:open', { runtime: { terminalBackend } })` + is called against a backend that exposes `launchTerminalPane` +- **THEN** `launchTerminalPane` is invoked exactly once +- **AND** the returned result has `ok === true`. + +#### Scenario: agent:start routes to runAddAgent +- **WHEN** `dispatchPaneAction('agent:start', { startAgentLane, runtime, ... })` + is called with a `worktreePath` in the context +- **THEN** the provided `startAgentLane` hook is invoked once with the + forwarded `task`, `agent`, `base`, and `worktreePath` fields. + +### Requirement: Cockpit ships a dispatchCockpitIntent helper +The cockpit module SHALL export a `dispatchCockpitIntent(intent, +context)` helper that takes a structured cockpit intent (the +`lastIntent` produced by control state transitions) and routes it +through `dispatchPaneAction`, merging the intent fields into the +dispatch context. + +#### Scenario: dispatchCockpitIntent merges intent into context +- **WHEN** `dispatchCockpitIntent({ type: 'terminal:open', sessionId, + branch, worktreePath }, { runtime: { terminalBackend } })` is called +- **THEN** the dispatched action context contains the intent's + `sessionId`, `branch`, and `worktreePath` +- **AND** `launchTerminalPane` is invoked with `actionId === 'add-terminal'` + and the merged worktree path. + +#### Scenario: dispatchCockpitIntent rejects empty intents +- **WHEN** `dispatchCockpitIntent(null, ...)` or + `dispatchCockpitIntent({}, ...)` is called +- **THEN** the result has `ok === false` and the message contains + `No cockpit intent`. + +### Requirement: Cockpit exposes COCKPIT_INTENT_ALIASES for external dispatchers +The cockpit module SHALL export a frozen `COCKPIT_INTENT_ALIASES` map +from intent types (`terminal:open`, `agent:start`) to action IDs +(`add-terminal`, `add-agent`) so external dispatchers can normalize +intent types if they prefer the action-ID surface. + +#### Scenario: Aliases map intent types to action IDs +- **WHEN** the cockpit module is required +- **THEN** `COCKPIT_INTENT_ALIASES['terminal:open']` equals + `'add-terminal'` and `COCKPIT_INTENT_ALIASES['agent:start']` equals + `'add-agent'`. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/tasks.md new file mode 100644 index 0000000..dd43d8e --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase6-terminal-2026-05-05-09-49/tasks.md @@ -0,0 +1,28 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-terminal-action/spec.md` + +## 2. Tests +- [x] 2.1 Add `test/cockpit-terminal-action.test.js` covering the + alias routing for `terminal:open`/`agent:start`, the + `dispatchCockpitIntent` helper, the missing-session + fall-back, and the empty-intent failure path. + +## 3. Implementation +- [x] 3.1 Add `'terminal:open'` and `'agent:start'` aliases to + `PANE_ACTION_HANDLERS` in `src/cockpit/pane-actions.js`. +- [x] 3.2 Add `COCKPIT_INTENT_ALIASES` mapping intent types to action + IDs. +- [x] 3.3 Add `dispatchCockpitIntent(intent, context)` helper that + merges intent fields into the dispatch context and routes + through `dispatchPaneAction`. +- [x] 3.4 Export `COCKPIT_INTENT_ALIASES` and `dispatchCockpitIntent` + from the module so external callers can use them. + +## 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/pane-actions.js b/src/cockpit/pane-actions.js index d44aff1..a7285ec 100644 --- a/src/cockpit/pane-actions.js +++ b/src/cockpit/pane-actions.js @@ -537,8 +537,33 @@ const PANE_ACTION_HANDLERS = Object.freeze({ 'add-terminal': runAddTerminal, 'add-agent': runAddAgent, 'reopen-closed-worktree': () => statusMessage('Reopen Closed Worktree', 'No closed worktree was restored.'), + 'terminal:open': runAddTerminal, + 'agent:start': runAddAgent, }); +const COCKPIT_INTENT_ALIASES = Object.freeze({ + 'terminal:open': 'add-terminal', + 'agent:start': 'add-agent', +}); + +function dispatchCockpitIntent(intent, context = {}) { + if (!intent || typeof intent !== 'object' || !intent.type) { + return resultShape({ ok: false, message: 'No cockpit intent to dispatch.' }); + } + const aliased = COCKPIT_INTENT_ALIASES[intent.type] || intent.type; + const merged = { + ...context, + ...intent, + sessionId: intent.sessionId || context.sessionId, + branch: intent.branch || context.branch, + worktreePath: intent.worktreePath || context.worktreePath, + task: intent.task || context.task, + agent: intent.agent || context.agent, + base: intent.base || context.base, + }; + return dispatchPaneAction(aliased, merged); +} + function dispatchPaneAction(action, context = {}) { const normalized = normalizeAction(action); const handler = PANE_ACTION_HANDLERS[normalized]; @@ -560,7 +585,9 @@ function dispatchPaneAction(action, context = {}) { } module.exports = { + COCKPIT_INTENT_ALIASES, PANE_ACTION_HANDLERS, + dispatchCockpitIntent, dispatchPaneAction, normalizeAction, operationContext, diff --git a/test/cockpit-terminal-action.test.js b/test/cockpit-terminal-action.test.js new file mode 100644 index 0000000..9034e63 --- /dev/null +++ b/test/cockpit-terminal-action.test.js @@ -0,0 +1,103 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + COCKPIT_INTENT_ALIASES, + dispatchCockpitIntent, + dispatchPaneAction, +} = require('../src/cockpit/pane-actions'); + +function fakeBackend() { + const calls = []; + return { + name: 'kitty', + launchTerminalPane(payload) { + calls.push({ method: 'launchTerminalPane', payload }); + return { ok: true, message: 'spawned terminal pane' }; + }, + calls, + }; +} + +test('terminal:open intent routes to runAddTerminal which calls launchTerminalPane', () => { + const backend = fakeBackend(); + const result = dispatchCockpitIntent( + { type: 'terminal:open', sessionId: 's1', branch: 'agent/codex/s1', worktreePath: '/repo/.omx/s1' }, + { + runtime: { terminalBackend: backend }, + repoRoot: '/repo/gitguardex', + }, + ); + assert.equal(result.ok, true); + assert.equal(backend.calls.length, 1); + assert.equal(backend.calls[0].method, 'launchTerminalPane'); + assert.equal(backend.calls[0].payload.actionId, 'add-terminal'); + assert.equal(backend.calls[0].payload.worktreePath, '/repo/.omx/s1'); +}); + +test('terminal:open intent without a session falls back to repoRoot for cwd', () => { + const backend = fakeBackend(); + const result = dispatchCockpitIntent( + { type: 'terminal:open' }, + { + runtime: { terminalBackend: backend }, + repoRoot: '/repo/gitguardex', + }, + ); + assert.equal(result.ok, true); + assert.equal(backend.calls[0].payload.repoRoot, '/repo/gitguardex'); +}); + +test('PANE_ACTION_HANDLERS exposes terminal:open as an alias for add-terminal', () => { + const backend = fakeBackend(); + const aliasResult = dispatchPaneAction('terminal:open', { + runtime: { terminalBackend: backend }, + repoRoot: '/repo/gitguardex', + }); + assert.equal(aliasResult.ok, true); + assert.equal(backend.calls.length, 1); + assert.equal(backend.calls[0].method, 'launchTerminalPane'); +}); + +test('agent:start intent routes to runAddAgent and forwards task/agent/base', () => { + const calls = []; + const result = dispatchCockpitIntent( + { + type: 'agent:start', + task: 'fix auth', + agent: 'codex', + base: 'main', + worktreePath: '/repo/.omx/active', + branch: 'agent/codex/active', + }, + { + runtime: { + terminalBackend: fakeBackend(), + }, + startAgentLane(request) { + calls.push(request); + return { ok: true, message: 'started agent lane' }; + }, + repoRoot: '/repo/gitguardex', + }, + ); + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].task, 'fix auth'); + assert.equal(calls[0].agent, 'codex'); + assert.equal(calls[0].base, 'main'); + assert.equal(calls[0].worktreePath, '/repo/.omx/active'); +}); + +test('dispatchCockpitIntent with no intent returns a structured failure', () => { + const result = dispatchCockpitIntent(null, {}); + assert.equal(result.ok, false); + assert.match(result.message, /No cockpit intent/i); +}); + +test('COCKPIT_INTENT_ALIASES maps intent types to action ids', () => { + assert.equal(COCKPIT_INTENT_ALIASES['terminal:open'], 'add-terminal'); + assert.equal(COCKPIT_INTENT_ALIASES['agent:start'], 'add-agent'); +});