diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/proposal.md new file mode 100644 index 0000000..70d64bc --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/proposal.md @@ -0,0 +1,47 @@ +# dmux-style cockpit — Phase 1: top-bar shortcut row + +## Why + +Users want `gx` (or `gx cockpit`) to look and feel like dmux — a TUI +multiplexer with a sidebar that exposes `[n]ew agent`, `[t]erminal`, +`[l]ogs`, `[p]rojects` as one-key shortcuts. Today the cockpit only +shows `[n]ew agent` and `[t]erminal`; `l` and `p` aren't wired and +there are no logs/projects modes. + +This change is phase 1 of a 5-6 PR plan that ends with full dmux +parity (logs viewer, project picker, branded welcome). Phase 1 lands +the top-bar surface, modes, and key dispatch so later phases only need +to fill in the actual log/project content. + +## What changes + +- Sidebar shortcut block expands from 2 rows to 3 rows. New row: + `[l]ogs [p]rojects`. +- New cockpit modes: `logs`, `projects`. Routed by `openActionRow`. +- Key dispatch: `l` always opens the logs panel; `p` opens the + projects panel **only when no lane is selected** (otherwise the + existing pane menu `p` action — "Create GitHub PR" — still wins). +- Two new placeholder panels render when those modes are active. They + describe what later phases will fill in (log filters, project + picker), so users see something visible the moment they press the + hotkey. +- Shortcuts help text updated to list `l` and `p`. + +## Impact + +- 2 new modes in `MODES`, 2 new entries in `EMPTY_ACTION_ROWS`. +- `applyKey` adds an action-scope `p` branch before the existing + `DIRECT_DETAIL_PANE_KEYS` block so PR-on-lane behavior is preserved. +- Existing snapshot test for the 2-row shortcut block updated to the + 3-row layout. +- No behavior change to safety model, branches, worktrees, locks, or + PR-only finish flow. + +## Out of scope (later phases) + +- Phase 2: Welcome screen redesign + gitguardex ASCII brand. +- Phase 3: Project picker overlay (scan workspace for git repos). +- Phase 4: Logs viewer overlay (tail `apps/logs/*.log`, lane events). +- Phase 5: New-agent prompt overlay wrapping `gx agents start`. +- Phase 6: Terminal pane action wired to top-bar `t` (currently opens + the existing terminal mode that already routes to Kitty). diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/specs/cockpit-control/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/specs/cockpit-control/spec.md new file mode 100644 index 0000000..cbcee17 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/specs/cockpit-control/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Cockpit exposes a dmux-style 4-action shortcut row +The cockpit sidebar SHALL render a dmux-style shortcut block with at +least four primary actions: `[n]ew agent`, `[t]erminal`, `[l]ogs`, +`[p]rojects`, plus the existing `[s]ettings` and `[?] shortcuts`. + +#### Scenario: Sidebar renders all four primary shortcuts +- **WHEN** `renderSidebar` is invoked with any state +- **THEN** the rendered output contains `[n]ew agent`, `[t]erminal`, + `[l]ogs`, `[p]rojects`, `[s]ettings`, and `[?] shortcuts` substrings. + +### Requirement: Cockpit dispatches `l` to a logs mode and `p` to a projects mode +The cockpit key handler SHALL route the `l` key to the `logs` mode +unconditionally, and SHALL route the `p` key to the `projects` mode +when no lane is selected. When a lane is selected, `p` SHALL keep its +existing pane-menu meaning (Create GitHub PR). + +#### Scenario: l opens the logs panel +- **WHEN** the cockpit is in `main` mode and the user presses `l` +- **THEN** the resulting state has `mode === 'logs'` and + `lastIntent === null`. + +#### Scenario: p opens projects when no lane is selected +- **WHEN** the cockpit is in `main` mode with `selectedScope === 'action'` + and the user presses `p` +- **THEN** the resulting state has `mode === 'projects'`. + +#### Scenario: p preserves the pane-menu action when a lane is selected +- **WHEN** the cockpit is in `main` mode with at least one lane selected + and the user presses `p` +- **THEN** the resulting state SHALL NOT have `mode === 'projects'` +- **AND** the existing pane-menu PR action SHALL fire. + +#### Scenario: Esc returns from logs/projects to main +- **WHEN** the cockpit is in `logs` or `projects` mode and the user + presses `Esc` +- **THEN** the resulting state has `mode === 'main'`. + +### Requirement: Logs and projects modes have placeholder render panels +The cockpit SHALL render a placeholder panel for the `logs` and +`projects` modes describing what later phases will fill in, so that +pressing `l` or `p` produces visible feedback before the real overlays +ship. + +#### Scenario: Logs panel renders a heading and filter row +- **WHEN** `renderPanel` is invoked with `mode === 'logs'` +- **THEN** the output contains a `gitguardex logs` heading +- **AND** the output contains the substring `[1] All [2] Info [3] + Warnings [4] Errors [5] By Pane`. + +#### Scenario: Projects panel renders a heading and switch hint +- **WHEN** `renderPanel` is invoked with `mode === 'projects'` +- **THEN** the output contains a `projects` heading +- **AND** the output contains an `Enter: switch to selected project` + hint. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/tasks.md new file mode 100644 index 0000000..c7be716 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase1-topbar-2026-05-05-09-01/tasks.md @@ -0,0 +1,35 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-control/spec.md` + +## 2. Tests +- [x] 2.1 Update `test/cockpit-sidebar.test.js` snapshot to include the + new `[l]ogs [p]rojects` row. +- [x] 2.2 Add control test asserting `l` opens `logs` mode and + returns to `main` on Esc. +- [x] 2.3 Add control test asserting `p` opens `projects` mode only + when no lane is selected, and the existing pane-menu PR action + still fires when a lane is selected. +- [x] 2.4 Add render-frame test asserting the dmux-style `[l]ogs` / + `[p]rojects` row appears in `renderControlFrame` output. + +## 3. Implementation +- [x] 3.1 Add `logs` and `projects` to `MODES` and + `EMPTY_ACTION_ROWS` in `src/cockpit/control.js`. +- [x] 3.2 Add `logs` / `projects` cases to `openActionRow`. +- [x] 3.3 In `applyKey`, route `l` to `openActionRow(..., 'logs')` + and route `p` to `openActionRow(..., 'projects')` only when + `current.selectedScope === 'action'`. +- [x] 3.4 Add `renderLogsPanel` and `renderProjectsPanel` placeholder + renderers and dispatch them from `renderPanel`. +- [x] 3.5 Extend the sidebar shortcut block in + `src/cockpit/sidebar.js` to a 3-row layout. +- [x] 3.6 Update the in-app shortcuts help text with `l` and `p`. + +## 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 f7ba1bf..6693d81 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -23,8 +23,8 @@ const DEFAULT_SETTINGS = { defaultBase: 'main', }; -const MODES = new Set(['main', 'menu', 'settings', 'shortcuts', 'new-agent', 'terminal']); -const EMPTY_ACTION_ROWS = Object.freeze(['new-agent', 'terminal', 'settings', 'shortcuts']); +const MODES = new Set(['main', 'menu', 'settings', 'shortcuts', 'new-agent', 'terminal', 'logs', 'projects']); +const EMPTY_ACTION_ROWS = Object.freeze(['new-agent', 'terminal', 'logs', 'projects', 'settings', 'shortcuts']); const SETTINGS_FIELDS = [ 'theme', 'sidebarWidth', @@ -346,6 +346,12 @@ function openActionRow(state, actionId) { if (actionId === 'shortcuts') { return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null }); } + if (actionId === 'logs') { + return normalizeControlState({ ...current, mode: 'logs', lastIntent: null }); + } + if (actionId === 'projects') { + return normalizeControlState({ ...current, mode: 'projects', lastIntent: null }); + } return normalizeControlState({ ...current, lastIntent: null }); } @@ -400,6 +406,9 @@ function applyKey(state, rawKey) { lastIntent: null, }); } + if (mode === 'main' && key === 'p' && current.selectedScope === 'action') { + return openActionRow(current, 'projects'); + } if (mode === 'main' && DIRECT_DETAIL_PANE_KEYS.has(normalizePaneMenuKey(rawKey))) { const result = applyPaneMenuKey(paneMenuStateFromControl(current), rawKey); if (result.action === 'select') { @@ -421,6 +430,9 @@ function applyKey(state, rawKey) { if (key === 't') { return openActionRow(current, 'terminal'); } + if (key === 'l') { + return openActionRow(current, 'logs'); + } if (key === '?') { return openActionRow(current, 'shortcuts'); } @@ -619,6 +631,8 @@ function renderShortcutsPanel() { 'enter: view selected lane / open selected action', 'n: new agent', 't: terminal', + 'l: logs', + 'p: projects (no lane selected)', 'm or Alt+Shift+M: pane menu', 's: settings', 'v/h/x/p/r/c/o/a/b/f/T/A: pane actions', @@ -658,6 +672,39 @@ function renderTerminalPanel(state) { ].join('\n'); } +function renderLogsPanel(state) { + const current = normalizeControlState(state); + const sessions = current.sessions.length; + return [ + 'gitguardex logs', + '', + `repo: ${current.repoPath || '-'}`, + `active lanes: ${sessions}`, + '', + '[1] All [2] Info [3] Warnings [4] Errors [5] By Pane', + '', + 'Live tail of `apps/logs/*.log` and lane heartbeats lands here.', + 'Esc: back to main', + '', + ].join('\n'); +} + +function renderProjectsPanel(state) { + const current = normalizeControlState(state); + return [ + 'projects', + '', + `current: ${current.repoPath || '(none)'}`, + '', + 'Enter: switch to selected project', + 'Esc: back to main', + '', + 'Picker scans for git repos under your workspace and switches the', + 'cockpit target to the chosen one.', + '', + ].join('\n'); +} + function renderMenuPanel(state) { const current = normalizeControlState(state); return renderPaneMenu(paneMenuStateFromControl(current), { width: 72, theme: current.settings.theme }); @@ -678,6 +725,8 @@ function renderPanel(state) { if (current.mode === 'shortcuts') return renderShortcutsPanel(current); if (current.mode === 'new-agent') return renderNewAgentPanel(current); if (current.mode === 'terminal') return renderTerminalPanel(current); + if (current.mode === 'logs') return renderLogsPanel(current); + if (current.mode === 'projects') return renderProjectsPanel(current); if (current.sessions.length === 0) { return renderWelcomePage(welcomeState(current), current.settings); } diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js index c4e3b11..ebba35b 100644 --- a/src/cockpit/sidebar.js +++ b/src/cockpit/sidebar.js @@ -203,6 +203,7 @@ function renderShortcutRows(width, options) { const theme = getCockpitTheme(options.theme, options); const rows = [ ' [n]ew agent [t]erminal', + ' [l]ogs [p]rojects', ' [s]ettings [?] shortcuts', ]; return rows.map((row) => colorize(boundLine(row, width), 'secondary', theme)); diff --git a/test/cockpit-control.test.js b/test/cockpit-control.test.js index 74f7312..4423f9b 100644 --- a/test/cockpit-control.test.js +++ b/test/cockpit-control.test.js @@ -153,6 +153,39 @@ test('applyCockpitAction handles dmux shortcut modes without launching agents', assert.equal(applyCockpitAction(newAgent, { type: 'key', key: 'esc' }).mode, 'main'); assert.equal(applyCockpitAction(terminal, { type: 'key', key: 'escape' }).mode, 'main'); assert.equal(applyCockpitAction(baseState, { type: 'key', key: 'q' }).shouldExit, true); + + const logs = applyCockpitAction(baseState, { type: 'key', key: 'l' }); + assert.equal(logs.mode, 'logs'); + assert.equal(logs.lastIntent, null); + assert.equal(applyCockpitAction(logs, { type: 'key', key: 'esc' }).mode, 'main'); +}); + +test('p opens projects when no lane is selected, but PR action when a lane is selected', () => { + const noLanesState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([]), + }); + const projects = applyCockpitAction(noLanesState, { type: 'key', key: 'p' }); + assert.equal(projects.mode, 'projects'); + assert.equal(projects.lastIntent, null); + + const withLaneState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([session('one')]), + }); + const paneAction = applyCockpitAction(withLaneState, { type: 'key', key: 'p' }); + assert.notEqual(paneAction.mode, 'projects'); +}); + +test('renderControlFrame surfaces the dmux-style logs and projects shortcut row', () => { + const { renderControlFrame } = require('../src/cockpit/control'); + const baseState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([]), + }); + const frame = renderControlFrame(baseState).replace(/\x1b\[[0-9;]*m/g, ''); + assert.match(frame, /\[l\]ogs/); + assert.match(frame, /\[p\]rojects/); }); test('applyCockpitAction maps enter to view selected lane', () => { @@ -180,10 +213,11 @@ test('applyCockpitAction keeps empty-lane navigation on action rows', () => { assert.equal(state.selectedScope, 'action'); assert.equal(state.actionIndex, 0); + const rowsCount = state.actionRows.length; state = applyCockpitAction(state, { type: 'key', key: 'k' }); assert.equal(state.selectedScope, 'action'); assert.equal(state.selectedIndex, 0); - assert.equal(state.actionIndex, 3); + assert.equal(state.actionIndex, rowsCount - 1); state = applyCockpitAction(state, { type: 'key', key: 'j' }); assert.equal(state.actionIndex, 0); diff --git a/test/cockpit-sidebar.test.js b/test/cockpit-sidebar.test.js index 1cb76e9..83bb0d6 100644 --- a/test/cockpit-sidebar.test.js +++ b/test/cockpit-sidebar.test.js @@ -22,6 +22,7 @@ test('renderSidebar renders an empty repo sidebar', () => { 'gitguardex', ' no agent lanes', ' [n]ew agent [t]erminal', + ' [l]ogs [p]rojects', ' [s]ettings [?] shortcuts', ]); }); @@ -162,6 +163,8 @@ test('renderSidebar keeps shortcuts visible', () => { assert.match(output, /\[n\]ew agent/); assert.match(output, /\[t\]erminal/); + assert.match(output, /\[l\]ogs/); + assert.match(output, /\[p\]rojects/); assert.match(output, /\[s\]ettings/); assert.match(output, /\[\?\] shortcuts/); });