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,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.
Original file line number Diff line number Diff line change
@@ -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'`.
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions src/cockpit/pane-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -560,7 +585,9 @@ function dispatchPaneAction(action, context = {}) {
}

module.exports = {
COCKPIT_INTENT_ALIASES,
PANE_ACTION_HANDLERS,
dispatchCockpitIntent,
dispatchPaneAction,
normalizeAction,
operationContext,
Expand Down
103 changes: 103 additions & 0 deletions test/cockpit-terminal-action.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading