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,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).
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 51 additions & 2 deletions src/cockpit/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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') {
Expand All @@ -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');
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/cockpit/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
36 changes: 35 additions & 1 deletion test/cockpit-control.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions test/cockpit-sidebar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
Expand Down Expand Up @@ -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/);
});
Loading