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 +85 -85
- package/src/claude-editor.ts +158 -10
- package/src/config.ts +2 -0
- package/src/editor/input-history-store.ts +173 -0
- package/src/index.ts +47 -2
- package/src/settings/cockpit-provider.ts +15 -2
- package/src/tui-i18n.ts +729 -725
- package/src/types.ts +300 -293
package/package.json
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-cockpit",
|
|
3
|
-
"version": "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
|
+
}
|
package/src/claude-editor.ts
CHANGED
|
@@ -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
|
|
89
|
-
* markers the fullscreen controller uses
|
|
90
|
-
* Installed at session start
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|