pi-cockpit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,7 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
66
66
  "agentsMode": "list",
67
67
  "todoMode": "list",
68
68
  "todoExpanded": false,
69
+ "stackStyle": "classic",
69
70
  "hideNativeAgents": true,
70
71
  "icons": { "mode": "auto" },
71
72
  "sidebar": {
@@ -91,6 +92,7 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
91
92
  - `quietSymbols`: `"check"` (✓/✗/⋯) or `"dot"` (●/○/◌) lifecycle glyphs for quiet tool rows.
92
93
  - `agentsMode` / `todoMode`: `"list"` or `"compact"`.
93
94
  - `todoExpanded`: when `true`, expands the todo widget by default.
95
+ - `stackStyle`: `"classic"` keeps the separate Todo/Agent widgets; `"zen"` projects MISSION, WORK, and ACTORS into one borderless stack. The default remains `"classic"`.
94
96
  - `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. On by default.
95
97
  - `sidebar.mode`: `"auto"` or `"on"` enables the dock when at least 72 main columns plus 32 sidebar columns fit; `"off"` always uses widgets.
96
98
  - `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
@@ -114,11 +116,12 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
114
116
  - `/cockpit` — opens the settings overlay.
115
117
  - `/maestro-settings` — opens the unified settings shell (Cockpit, Flow, Teammate and integrations).
116
118
  - `Alt+J` — opens the background Bash jobs overlay (live status, command, cwd, duration, output tail).
119
+ - `Alt+L` — browses the visible Cockpit surface. In Zen widget mode, arrows or `j/k` select rows, first Enter expands inline details, second Enter opens the entity sheet/overlay, and Esc steps back. In the Agent overlay, `m` makes the selected agent the editor input target.
117
120
  - `/cockpit sidebar` — reports the current sidebar mode, width, and density.
118
121
  - `/cockpit sidebar auto|on|off` — selects dock behavior.
119
122
  - `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
120
123
  - `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
121
- - `/theme` — switch theme with live preview; `/theme <name>` applies directly. Pi ships no standalone theme command; cockpit provides one.
124
+ - `/theme` — switch theme with live preview; `/theme <name>` applies directly. `cockpit-zen` is the restrained warm-gold theme designed for the Zen stack; selecting it does not change `stackStyle`. Pi ships no standalone theme command; cockpit provides one.
122
125
 
123
126
  ## Sidebar compatibility
124
127
 
package/package.json CHANGED
@@ -1,85 +1,85 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.14.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "scripts": {
21
- "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts",
22
- "typecheck": "tsc --noEmit",
23
- "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
- "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
- },
26
- "pi": {
27
- "extensions": [
28
- "./src/extension/index.ts"
29
- ],
30
- "themes": [
31
- "./themes"
32
- ]
33
- },
34
- "dependencies": {
35
- "pi-maestro-settings-core": "0.1.3",
36
- "proper-lockfile": "4.1.2"
37
- },
38
- "bundledDependencies": [
39
- "pi-maestro-settings-core"
40
- ],
41
- "peerDependencies": {
42
- "@earendil-works/pi-agent-core": "*",
43
- "@earendil-works/pi-ai": "*",
44
- "@earendil-works/pi-coding-agent": "*",
45
- "@earendil-works/pi-tui": "*",
46
- "pi-maestro-teammate": "^1.6.0",
47
- "typebox": "*"
48
- },
49
- "peerDependenciesMeta": {
50
- "@earendil-works/pi-agent-core": {
51
- "optional": true
52
- },
53
- "@earendil-works/pi-ai": {
54
- "optional": true
55
- },
56
- "@earendil-works/pi-coding-agent": {
57
- "optional": true
58
- },
59
- "@earendil-works/pi-tui": {
60
- "optional": true
61
- },
62
- "pi-maestro-teammate": {
63
- "optional": true
64
- },
65
- "typebox": {
66
- "optional": true
67
- }
68
- },
69
- "devDependencies": {
70
- "@earendil-works/pi-agent-core": "0.83.0",
71
- "@earendil-works/pi-ai": "0.83.0",
72
- "@earendil-works/pi-coding-agent": "0.83.0",
73
- "@earendil-works/pi-tui": "0.83.0",
74
- "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "1.12.0",
76
- "typebox": "1.3.7",
77
- "typescript": "^5.5.0"
78
- },
79
- "main": "./src/index.ts",
80
- "exports": {
81
- ".": "./src/index.ts",
82
- "./v1/events": "./src/public/v1/events.ts",
83
- "./src/*": "./src/*"
84
- }
85
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.16.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.test.ts tests/input-history-store.test.ts tests/unified-editor.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
+ "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/extension/index.ts"
29
+ ],
30
+ "themes": [
31
+ "./themes"
32
+ ]
33
+ },
34
+ "dependencies": {
35
+ "pi-maestro-settings-core": "0.1.3",
36
+ "proper-lockfile": "4.1.2"
37
+ },
38
+ "bundledDependencies": [
39
+ "pi-maestro-settings-core"
40
+ ],
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-agent-core": "*",
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "pi-maestro-teammate": "^1.6.0",
47
+ "typebox": "*"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@earendil-works/pi-agent-core": {
51
+ "optional": true
52
+ },
53
+ "@earendil-works/pi-ai": {
54
+ "optional": true
55
+ },
56
+ "@earendil-works/pi-coding-agent": {
57
+ "optional": true
58
+ },
59
+ "@earendil-works/pi-tui": {
60
+ "optional": true
61
+ },
62
+ "pi-maestro-teammate": {
63
+ "optional": true
64
+ },
65
+ "typebox": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "@earendil-works/pi-agent-core": "0.83.0",
71
+ "@earendil-works/pi-ai": "0.83.0",
72
+ "@earendil-works/pi-coding-agent": "0.83.0",
73
+ "@earendil-works/pi-tui": "0.83.0",
74
+ "@types/node": "^22.0.0",
75
+ "pi-maestro-teammate": "1.13.0",
76
+ "typebox": "1.3.7",
77
+ "typescript": "^5.5.0"
78
+ },
79
+ "main": "./src/index.ts",
80
+ "exports": {
81
+ ".": "./src/index.ts",
82
+ "./v1/events": "./src/public/v1/events.ts",
83
+ "./src/*": "./src/*"
84
+ }
85
+ }
@@ -9,7 +9,7 @@ import {
9
9
  truncateToWidth,
10
10
  visibleWidth,
11
11
  } from "@earendil-works/pi-tui";
12
- import { effectiveAgentStatus, type AgentDisplayStatus } from "./agents-store.ts";
12
+ import { effectiveAgentStatus, isExpertLeader, type AgentDisplayStatus } from "./agents-store.ts";
13
13
  import { scrollBy, type AgentScrollState } from "./agent-scroll.ts";
14
14
  import type { IconGlyphs } from "./icons.ts";
15
15
  import { visibleStart } from "./layout.ts";
@@ -28,6 +28,8 @@ export interface AgentOverlayParams {
28
28
  getAgents: () => readonly AgentRow[];
29
29
  getViewingId: () => string | undefined;
30
30
  onSelect: (correlationId: string) => void;
31
+ /** Set the selected agent as the editor input target without closing the overlay. */
32
+ onTarget?: (correlationId: string) => void;
31
33
  onCommand: (correlationId: string, action: "interrupt" | "steer", message?: string) => void;
32
34
  requestRender: () => void;
33
35
  close: () => void;
@@ -289,6 +291,11 @@ export class AgentOverlay implements Component, Focusable {
289
291
  this.scrollOutput(Math.max(1, this.detailRows - 2));
290
292
  return;
291
293
  }
294
+ if (commandInput === "m" && this.selectedId && this.params.onTarget) {
295
+ this.params.onTarget(this.selectedId);
296
+ this.ack(tuiT("overlay.agents.targetAck", { agent: this.agentName(this.selectedId) }));
297
+ return;
298
+ }
292
299
  if (commandInput === "i" && this.selectedId) {
293
300
  // Interrupt (打断): abort the current turn/tool; the agent stays alive
294
301
  // and is told to report and continue.
@@ -444,8 +451,11 @@ export class AgentOverlay implements Component, Focusable {
444
451
  const visual = agentVisual(status, this.params.glyphs);
445
452
  const marker = row.correlationId === this.selectedId ? this.params.glyphs.selectMarker : " ";
446
453
  const task = row.task ? ` · ${row.task}` : "";
454
+ const expertTag = isExpertLeader(row)
455
+ ? `${this.params.theme.fg("accent", tuiT("widget.agent.expert"))} `
456
+ : "";
447
457
  return fit(
448
- `${prefix}${marker} ${this.params.theme.fg(visual.color, visual.glyph)} ${this.params.theme.bold(agentLabel(row))}${task}`,
458
+ `${prefix}${marker} ${this.params.theme.fg(visual.color, visual.glyph)} ${expertTag}${this.params.theme.bold(agentLabel(row))}${task}`,
449
459
  width,
450
460
  );
451
461
  });
@@ -5,7 +5,7 @@ import type {
5
5
  } from "pi-maestro-teammate/v1/events";
6
6
  import type { AgentProgressSnapshot } from "pi-maestro-teammate/v1/types";
7
7
  import { sanitizeExtensionStatusText } from "./extension-status.ts";
8
- import type { AgentRow, AgentStatus } from "./types.ts";
8
+ import { EXPERT_LEADER_NAME, type AgentRow, type AgentStatus } from "./types.ts";
9
9
 
10
10
  const STATUS_TEXT_MAX = 48;
11
11
  const TEAMMATE_STALL_TIMEOUT_MS = 30_000;
@@ -99,6 +99,16 @@ function deriveRole(agent: string | undefined, name: string | undefined): string
99
99
  return clean(name) || "agent";
100
100
  }
101
101
 
102
+ /**
103
+ * True when the row is the workflow Leader of an expert-mode dispatch
104
+ * (pi-maestro-teammate EXPERT_MODE_LEADER_NAME). The Leader keeps the same
105
+ * tree + dependency-arrow presentation as parallel/DAG runs; this only adds
106
+ * its strategy marker.
107
+ */
108
+ export function isExpertLeader(row: { name: string | undefined }): boolean {
109
+ return row.name === EXPERT_LEADER_NAME;
110
+ }
111
+
102
112
  export function mapAgentStatus(status: unknown): AgentStatus {
103
113
  switch (status) {
104
114
  case "pending":
@@ -25,6 +25,8 @@ export interface BashBgOverlayParams {
25
25
  close: () => void;
26
26
  theme: Theme;
27
27
  glyphs: IconGlyphs;
28
+ /** Select this job when the overlay first renders. */
29
+ initialJobId?: string;
28
30
  /** Wall-clock snapshot supplied by the owner and advanced through tick(). */
29
31
  now: number;
30
32
  /** Live terminal height, so the card can use the space it already reserves. */
@@ -57,6 +59,7 @@ export class BashBgOverlay implements Component, Focusable {
57
59
  constructor(private readonly params: BashBgOverlayParams) {
58
60
  this.now = params.now;
59
61
  this.liveTickKey = this.tickKey(params.now);
62
+ this.selectedId = params.initialJobId;
60
63
  }
61
64
 
62
65
  invalidate(): void {}
@@ -1,6 +1,7 @@
1
1
  import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
- import { isKeyRelease, isKeyRepeat, matchesKey, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
2
+ import { isKeyRelease, isKeyRepeat, matchesKey, getKeybindings, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
3
  import { readAutocompleteState } from "./autocomplete-probe.ts";
4
+ import { tuiT } from "./tui-i18n.ts";
4
5
 
5
6
  /** Invisible editor start marker consumed by the fullscreen controller (fullscreenInput only). */
6
7
  export const EDITOR_START_SENTINEL = "\u2063\u2064\u2063\u2064cockpit:editor:start";
@@ -9,6 +10,15 @@ export const EDITOR_END_SENTINEL = "\u2063\u2064\u2063\u2064cockpit:editor:end";
9
10
 
10
11
  export const DEFAULT_DOUBLE_ESCAPE_WINDOW_MS = 500;
11
12
 
13
+ /** Cursor state when the user is typing rather than browsing history. */
14
+ const NOT_BROWSING = -1;
15
+
16
+ export interface CockpitEditorRouteTarget {
17
+ label: string;
18
+ sigil?: "@" | "#";
19
+ paint: (text: string) => string;
20
+ }
21
+
12
22
  export interface CockpitClaudeEditorOptions {
13
23
  /** Enable the double bare-Escape clear-input state machine. */
14
24
  doubleEscapeClearInput: boolean;
@@ -22,6 +32,14 @@ export interface CockpitClaudeEditorOptions {
22
32
  onError?: (error: unknown) => void;
23
33
  /** Double-Escape window in ms (default 500). */
24
34
  doubleEscapeWindowMs?: number;
35
+ /** Persistent prompt history, newest first. Read on every keystroke. */
36
+ getEntries?: () => readonly string[];
37
+ /** Called for every submitted prompt (including slash commands and `!` lines). */
38
+ record?: (text: string) => void;
39
+ /** Immutable route prefix painted inside the editor, never included in text. */
40
+ getRouteTarget?: () => CockpitEditorRouteTarget | undefined;
41
+ /** Called with the constructed editor so the host can push route updates. */
42
+ onEditor?: (editor: CockpitClaudeEditor) => void;
25
43
  }
26
44
 
27
45
  export interface DoubleEscapeDecision {
@@ -85,13 +103,18 @@ export class DoubleEscapeGate {
85
103
  }
86
104
 
87
105
  /**
88
- * Cockpit's CustomEditor: owns the double-Escape state machine and the editor
89
- * markers the fullscreen controller uses to split transcript / editor / chrome.
90
- * Installed at session start (reload-gated) via createCockpitClaudeEditorFactory.
106
+ * Cockpit's unified CustomEditor: owns the double-Escape state machine, the
107
+ * editor markers the fullscreen controller uses, the persistent ↑/↓ prompt
108
+ * history, and the input route-target prefix. Installed at session start
109
+ * (reload-gated) via createCockpitClaudeEditorFactory.
91
110
  */
92
111
  export class CockpitClaudeEditor extends CustomEditor {
93
112
  private readonly editorOptions: CockpitClaudeEditorOptions;
94
113
  private readonly doubleEscapeGate = new DoubleEscapeGate();
114
+ /** History browsing cursor: NOT_BROWSING while typing, else an index into getEntries(). */
115
+ private index = NOT_BROWSING;
116
+ /** What the user had typed before browsing started, restored on the way back down. */
117
+ private draft = "";
95
118
 
96
119
  constructor(
97
120
  tui: TUI,
@@ -102,17 +125,39 @@ export class CockpitClaudeEditor extends CustomEditor {
102
125
  super(tui, theme, keybindings);
103
126
  this.editorOptions = options;
104
127
  if (options.doubleEscapeWindowMs !== undefined) this.doubleEscapeGate.windowMs = options.doubleEscapeWindowMs;
128
+ options.onEditor?.(this);
129
+ }
130
+
131
+ /** Submitted prompts go to the persistent store when enabled, else to the base in-memory history. */
132
+ override addToHistory(text: string): void {
133
+ if (this.editorOptions.record) this.editorOptions.record(text);
134
+ else super.addToHistory(text);
135
+ }
136
+
137
+ /** Programmatic writes (submit clears, `ui.setEditorText`) end history browsing. */
138
+ override setText(text: string): void {
139
+ this.index = NOT_BROWSING;
140
+ super.setText(text);
105
141
  }
106
142
 
107
143
  override render(width: number): string[] {
108
- const lines = super.render(width);
109
- if (!this.editorOptions.emitEditorMarkers) return lines;
110
- return [EDITOR_START_SENTINEL, ...lines, EDITOR_END_SENTINEL];
144
+ const target = this.editorOptions.getRouteTarget?.();
145
+ const lines = target ? this.renderWithRouteTarget(width, target) : super.render(width);
146
+ const total = this.editorOptions.getEntries?.().length ?? 0;
147
+ const banner = this.browsing() && total > 0
148
+ ? historyBanner(this.index + 1, total, width, this.getPaddingX(), this.borderColor)
149
+ : undefined;
150
+ if (!this.editorOptions.emitEditorMarkers) {
151
+ return banner ? [...lines, banner] : lines;
152
+ }
153
+ // The fullscreen controller treats everything between the sentinels as the
154
+ // editor region, so the banner must sit before the END marker to stay visible.
155
+ return [EDITOR_START_SENTINEL, ...lines, ...(banner ? [banner] : []), EDITOR_END_SENTINEL];
111
156
  }
112
157
 
113
158
  override handleInput(data: string): void {
114
- const enabled = this.editorOptions.doubleEscapeClearInput;
115
159
  if (this.isBareEscape(data)) {
160
+ const enabled = this.editorOptions.doubleEscapeClearInput;
116
161
  if (enabled) {
117
162
  const probe = readAutocompleteState(this);
118
163
  const autocomplete: DoubleEscapeGateInput["autocomplete"] = probe.unknown
@@ -133,8 +178,30 @@ export class CockpitClaudeEditor extends CustomEditor {
133
178
  super.handleInput(data);
134
179
  return;
135
180
  }
136
- if (enabled) this.doubleEscapeGate.onAnyOtherInput();
181
+ if (this.editorOptions.doubleEscapeClearInput) this.doubleEscapeGate.onAnyOtherInput();
182
+ const keys = getKeybindings();
183
+ // History browsing only exists when persistent history is enabled; without
184
+ // getEntries, Up/Down keep their plain cursor-move meaning.
185
+ if (this.editorOptions.getEntries) {
186
+ if (keys.matches(data, "tui.editor.cursorUp") && this.canBrowseOlder()) {
187
+ this.step(1);
188
+ return;
189
+ }
190
+ if (keys.matches(data, "tui.editor.cursorDown") && this.canBrowseNewer()) {
191
+ this.step(-1);
192
+ return;
193
+ }
194
+ }
137
195
  super.handleInput(data);
196
+ // Editing the recalled text means the user left history behind.
197
+ if (this.browsing() && this.getText() !== this.editorOptions.getEntries?.()[this.index]) {
198
+ this.index = NOT_BROWSING;
199
+ }
200
+ }
201
+
202
+ /** Repaint after a cross-extension input-target change. */
203
+ refreshRouteTarget(): void {
204
+ this.tui.requestRender();
138
205
  }
139
206
 
140
207
  private isBareEscape(data: string): boolean {
@@ -149,9 +216,90 @@ export class CockpitClaudeEditor extends CustomEditor {
149
216
  this.editorOptions.onError?.(error);
150
217
  }
151
218
  }
219
+
220
+ private renderWithRouteTarget(width: number, target: CockpitEditorRouteTarget): string[] {
221
+ const paddingX = Math.min(this.getPaddingX(), Math.max(0, Math.floor((width - 1) / 2)));
222
+ const contentWidth = Math.max(1, width - paddingX * 2);
223
+ const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
224
+ const token = truncateToWidth(`${target.sigil ?? "@"}${target.label}:`, Math.max(1, layoutWidth - 1), "…");
225
+ const injected = `${token}${visibleWidth(token) < layoutWidth ? " " : ""}`;
226
+
227
+ // Editor has no render-prefix API. Temporarily project the immutable target
228
+ // into its layout state so native wrapping and cursor placement stay correct,
229
+ // then restore the real editable state before returning.
230
+ const state = (this as unknown as EditorWithRenderState).state;
231
+ const originalLines = state.lines;
232
+ const originalCursorCol = state.cursorCol;
233
+ state.lines = [`${injected}${originalLines[0] ?? ""}`, ...originalLines.slice(1)];
234
+ if (state.cursorLine === 0) state.cursorCol += injected.length;
235
+ try {
236
+ const rendered = super.render(width);
237
+ const routeLine = rendered.findIndex((line) => line.includes(token));
238
+ if (routeLine >= 0) {
239
+ rendered[routeLine] = rendered[routeLine]!.replace(token, target.paint(token));
240
+ }
241
+ return rendered.map((line) => truncateToWidth(line, Math.max(1, width), ""));
242
+ } finally {
243
+ state.lines = originalLines;
244
+ state.cursorCol = originalCursorCol;
245
+ }
246
+ }
247
+
248
+ private browsing(): boolean {
249
+ return this.index > NOT_BROWSING;
250
+ }
251
+
252
+ /**
253
+ * Mirrors the base editor's own gating: on the first line, and either already
254
+ * browsing or with the cursor at the very start (which includes an empty editor) —
255
+ * anywhere else Up is a plain cursor move.
256
+ */
257
+ private canBrowseOlder(): boolean {
258
+ if (this.isShowingAutocomplete() || this.getCursor().line !== 0) return false;
259
+ return this.browsing() || this.getCursor().col === 0;
260
+ }
261
+
262
+ private canBrowseNewer(): boolean {
263
+ if (!this.browsing() || this.isShowingAutocomplete()) return false;
264
+ return this.getCursor().line === this.getLines().length - 1;
265
+ }
266
+
267
+ private step(delta: number): void {
268
+ const entries = this.editorOptions.getEntries?.() ?? [];
269
+ const next = this.index + delta;
270
+ if (next < NOT_BROWSING || next >= entries.length) return;
271
+ if (!this.browsing()) this.draft = this.getText();
272
+ // super.setText, so the index we are about to record survives.
273
+ super.setText(next === NOT_BROWSING ? this.draft : entries[next] ?? "");
274
+ this.index = next;
275
+ }
276
+ }
277
+
278
+ /** Compact history position label, indented to the editor's content. */
279
+ export function historyBanner(
280
+ position: number,
281
+ total: number,
282
+ width: number,
283
+ paddingX: number,
284
+ paint: (value: string) => string,
285
+ ): string {
286
+ const indent = " ".repeat(Math.max(0, paddingX));
287
+ const inner = Math.max(1, width - indent.length * 2);
288
+ const label = tuiT("editor.historyBanner", { position, total });
289
+ return indent + paint(truncateToWidth(label, inner, "…"));
290
+ }
291
+
292
+ interface EditorRenderState {
293
+ lines: string[];
294
+ cursorLine: number;
295
+ cursorCol: number;
296
+ }
297
+
298
+ interface EditorWithRenderState {
299
+ state: EditorRenderState;
152
300
  }
153
301
 
154
- /** Build the editor factory Cockpit installs when a gated feature is on. */
302
+ /** Build the editor factory Cockpit installs for its gated editor features. */
155
303
  export function createCockpitClaudeEditorFactory(options: CockpitClaudeEditorOptions) {
156
304
  const factory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
157
305
  new CockpitClaudeEditor(tui, theme, keybindings, options);
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
- import { type CockpitConfig, type CurrencyMode, DEFAULT_CONFIG } from "./types.ts";
4
+ import { type CockpitConfig, type CurrencyMode, DEFAULT_CONFIG, type StackStyle } from "./types.ts";
5
5
 
6
6
  const SIDEBAR_MIN_WIDTH = 32;
7
7
  const SIDEBAR_MAX_WIDTH = 56;
@@ -19,6 +19,7 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
19
19
  const isToolPaletteMode = (v: unknown): v is "classic" | "family" | "readwrite" | "search" | "mono" =>
20
20
  v === "classic" || v === "family" || v === "readwrite" || v === "search" || v === "mono";
21
21
  const isIconMode = (v: unknown): v is "auto" | "nerd" | "ascii" => v === "auto" || v === "nerd" || v === "ascii";
22
+ const isStackStyle = (v: unknown): v is StackStyle => v === "classic" || v === "zen";
22
23
  const isCurrencyMode = (v: unknown): v is CurrencyMode => v === "usd" || v === "cny";
23
24
  const isSidebarMode = (v: unknown): v is "auto" | "on" | "off" => v === "auto" || v === "on" || v === "off";
24
25
  const isSidebarDensity = (v: unknown): v is "comfortable" | "compact" => v === "comfortable" || v === "compact";
@@ -47,11 +48,13 @@ const isCurrencyMode = (v: unknown): v is CurrencyMode => v === "usd" || v === "
47
48
  agentsMode: isMode(o.agentsMode) ? o.agentsMode : base.agentsMode,
48
49
  todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
49
50
  todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
51
+ stackStyle: isStackStyle(o.stackStyle) ? o.stackStyle : base.stackStyle,
50
52
  hideNativeAgents: typeof o.hideNativeAgents === "boolean" ? o.hideNativeAgents : base.hideNativeAgents,
51
53
  pinEditorBottom: typeof o.pinEditorBottom === "boolean" ? o.pinEditorBottom : base.pinEditorBottom,
52
54
  doubleEscapeClearInput: typeof o.doubleEscapeClearInput === "boolean" ? o.doubleEscapeClearInput : base.doubleEscapeClearInput,
53
55
  fullscreenInput: typeof o.fullscreenInput === "boolean" ? o.fullscreenInput : base.fullscreenInput,
54
56
  copyOnSelect: typeof o.copyOnSelect === "boolean" ? o.copyOnSelect : base.copyOnSelect,
57
+ historyEnabled: typeof o.historyEnabled === "boolean" ? o.historyEnabled : base.historyEnabled,
55
58
  currency: isCurrencyMode(o.currency) ? o.currency : base.currency,
56
59
  currencyRate: typeof o.currencyRate === "number" && Number.isFinite(o.currencyRate) && o.currencyRate > 0
57
60
  ? Math.min(100, Math.max(0.01, Math.round(o.currencyRate * 100) / 100))
@@ -133,11 +136,13 @@ export function mergeConfigDocument(raw: unknown, config: CockpitConfig): Record
133
136
  agentsMode: config.agentsMode,
134
137
  todoMode: config.todoMode,
135
138
  todoExpanded: config.todoExpanded,
139
+ stackStyle: config.stackStyle,
136
140
  hideNativeAgents: config.hideNativeAgents,
137
141
  pinEditorBottom: config.pinEditorBottom,
138
142
  doubleEscapeClearInput: config.doubleEscapeClearInput,
139
143
  fullscreenInput: config.fullscreenInput,
140
144
  copyOnSelect: config.copyOnSelect,
145
+ historyEnabled: config.historyEnabled,
141
146
  currency: config.currency,
142
147
  currencyRate: config.currencyRate,
143
148
  icons,