pi-cockpit 0.15.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/package.json CHANGED
@@ -1,85 +1,85 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.15.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/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
- }
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
+ }
@@ -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
@@ -54,6 +54,7 @@ const isCurrencyMode = (v: unknown): v is CurrencyMode => v === "usd" || v === "
54
54
  doubleEscapeClearInput: typeof o.doubleEscapeClearInput === "boolean" ? o.doubleEscapeClearInput : base.doubleEscapeClearInput,
55
55
  fullscreenInput: typeof o.fullscreenInput === "boolean" ? o.fullscreenInput : base.fullscreenInput,
56
56
  copyOnSelect: typeof o.copyOnSelect === "boolean" ? o.copyOnSelect : base.copyOnSelect,
57
+ historyEnabled: typeof o.historyEnabled === "boolean" ? o.historyEnabled : base.historyEnabled,
57
58
  currency: isCurrencyMode(o.currency) ? o.currency : base.currency,
58
59
  currencyRate: typeof o.currencyRate === "number" && Number.isFinite(o.currencyRate) && o.currencyRate > 0
59
60
  ? Math.min(100, Math.max(0.01, Math.round(o.currencyRate * 100) / 100))
@@ -141,6 +142,7 @@ export function mergeConfigDocument(raw: unknown, config: CockpitConfig): Record
141
142
  doubleEscapeClearInput: config.doubleEscapeClearInput,
142
143
  fullscreenInput: config.fullscreenInput,
143
144
  copyOnSelect: config.copyOnSelect,
145
+ historyEnabled: config.historyEnabled,
144
146
  currency: config.currency,
145
147
  currencyRate: config.currencyRate,
146
148
  icons,
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Per-workspace input history, owned by Cockpit's unified editor.
3
+ *
4
+ * The prompt history Pi's editor offers on up/down lives in memory only, so it is
5
+ * gone the moment the session ends. This store mirrors it to
6
+ * `~/.pi/workspaces/<workspace-id>/input-history.json`, keyed by working directory,
7
+ * so a brand new session picks up where the previous one left off.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { basename, join, resolve } from "node:path";
14
+
15
+ const FILE_NAME = "input-history.json";
16
+ /** Matches the built-in editor's in-memory cap, so the list feels the same. */
17
+ const DEFAULT_MAX_ENTRIES = 100;
18
+ /** Session restore replays every past prompt; one debounce window collapses it into one write. */
19
+ const DEFAULT_SAVE_DEBOUNCE_MS = 250;
20
+ const PRIVATE_DIRECTORY_MODE = 0o700;
21
+ const PRIVATE_FILE_MODE = 0o600;
22
+
23
+ /**
24
+ * Serializes every save across store instances so a fresh store's load() never
25
+ * races an older store's pending flush (reload/new/resume in the same process):
26
+ * the next load waits for the previous store's save to land before reading.
27
+ */
28
+ let pendingWrites: Promise<void> = Promise.resolve();
29
+
30
+ export interface InputHistoryStoreOptions {
31
+ rootDir?: string;
32
+ maxEntries?: number;
33
+ debounceMs?: number;
34
+ onError?: (error: unknown) => void;
35
+ }
36
+
37
+ /**
38
+ * Stable per-workspace storage id, byte-identical to the historical
39
+ * pi-maestro-flow implementation so persisted history keeps resolving to the
40
+ * same file across the module move.
41
+ */
42
+ export function workspaceStorageId(cwd: string): string {
43
+ const normalized = normalizeWorkspacePath(cwd);
44
+ const slug = basename(resolve(cwd))
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9._-]+/g, "-")
47
+ .replace(/^-+|-+$/g, "") || "workspace";
48
+ return `${slug}-${createHash("sha256").update(normalized).digest("hex").slice(0, 8)}`;
49
+ }
50
+
51
+ function normalizeWorkspacePath(cwd: string): string {
52
+ const normalized = resolve(cwd).replaceAll("\\", "/").replace(/\/$/, "");
53
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
54
+ }
55
+
56
+ export class InputHistoryStore {
57
+ readonly filePath: string;
58
+ private readonly dir: string;
59
+ private readonly maxEntries: number;
60
+ private readonly debounceMs: number;
61
+ private readonly onError: ((error: unknown) => void) | undefined;
62
+ private entries: string[] = [];
63
+ private timer: NodeJS.Timeout | undefined;
64
+ private writes: Promise<void> = Promise.resolve();
65
+
66
+ constructor(cwd: string, options: InputHistoryStoreOptions = {}) {
67
+ const rootDir = options.rootDir ?? join(homedir(), ".pi", "workspaces");
68
+ this.dir = join(rootDir, workspaceStorageId(cwd));
69
+ this.filePath = join(this.dir, FILE_NAME);
70
+ this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
71
+ this.debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS);
72
+ this.onError = options.onError;
73
+ }
74
+
75
+ /** Newest first. */
76
+ list(): readonly string[] {
77
+ return this.entries;
78
+ }
79
+
80
+ async load(): Promise<readonly string[]> {
81
+ // Let any older store's pending save land first, so a session that starts
82
+ // right after a shutdown sees the previous session's last prompt.
83
+ await pendingWrites;
84
+ const disk = await this.readFromDisk();
85
+ // Entries recorded before the first load settled (an immediate submit right
86
+ // after startup) must survive: keep them ahead of the disk entries.
87
+ const known = new Set(this.entries);
88
+ this.entries = capped([...this.entries, ...disk.filter((value) => !known.has(value))], this.maxEntries);
89
+ return this.entries;
90
+ }
91
+
92
+ /** Move `text` to the front of the history and schedule a save. */
93
+ record(text: string): void {
94
+ const entry = text.trim();
95
+ if (!entry || this.entries[0] === entry) return;
96
+ this.entries = capped([entry, ...this.entries.filter((value) => value !== entry)], this.maxEntries);
97
+ this.schedule();
98
+ }
99
+
100
+ /** Run any pending save now and wait for every outstanding write. */
101
+ async flush(): Promise<void> {
102
+ if (this.timer) {
103
+ clearTimeout(this.timer);
104
+ this.timer = undefined;
105
+ this.enqueueSave();
106
+ }
107
+ await this.writes;
108
+ }
109
+
110
+ private schedule(): void {
111
+ if (this.timer) return;
112
+ this.timer = setTimeout(() => {
113
+ this.timer = undefined;
114
+ this.enqueueSave();
115
+ }, this.debounceMs);
116
+ }
117
+
118
+ private enqueueSave(): void {
119
+ const save = pendingWrites.then(() => this.save());
120
+ pendingWrites = save.catch(() => undefined);
121
+ this.writes = save.catch((error: unknown) => {
122
+ this.onError?.(error);
123
+ });
124
+ }
125
+
126
+ private async save(): Promise<void> {
127
+ // Another pi window in the same workspace may have written since we loaded;
128
+ // keep whatever it added instead of overwriting it.
129
+ const known = new Set(this.entries);
130
+ const disk = (await this.readFromDisk()).filter((value) => !known.has(value));
131
+ this.entries = capped([...this.entries, ...disk], this.maxEntries);
132
+ await mkdir(this.dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
133
+ const temp = `${this.filePath}.${process.pid}.tmp`;
134
+ try {
135
+ const payload = JSON.stringify({ version: 1, entries: this.entries }, null, 2);
136
+ await writeFile(temp, payload, { mode: PRIVATE_FILE_MODE });
137
+ await rename(temp, this.filePath);
138
+ } catch (error) {
139
+ await rm(temp, { force: true });
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ private async readFromDisk(): Promise<string[]> {
145
+ try {
146
+ return parseEntries(JSON.parse(await readFile(this.filePath, "utf8")) as unknown);
147
+ } catch (error) {
148
+ // No history yet is the normal first-run state, not a failure.
149
+ if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return [];
150
+ this.onError?.(error);
151
+ return [];
152
+ }
153
+ }
154
+ }
155
+
156
+ function parseEntries(raw: unknown): string[] {
157
+ const list = (raw as { entries?: unknown } | null)?.entries;
158
+ if (!Array.isArray(list)) return [];
159
+ const seen = new Set<string>();
160
+ const entries: string[] = [];
161
+ for (const value of list) {
162
+ if (typeof value !== "string") continue;
163
+ const entry = value.trim();
164
+ if (!entry || seen.has(entry)) continue;
165
+ seen.add(entry);
166
+ entries.push(entry);
167
+ }
168
+ return entries;
169
+ }
170
+
171
+ function capped(entries: string[], maxEntries: number): string[] {
172
+ return entries.length > maxEntries ? entries.slice(0, maxEntries) : entries;
173
+ }