@groeponline/pi-wishcraft 0.17.3
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/AGENTS.md +68 -0
- package/CHANGELOG.md +724 -0
- package/CONTRIBUTING.md +37 -0
- package/README.md +648 -0
- package/RELEASE.md +117 -0
- package/ROADMAP.md +52 -0
- package/bash-mode/completion-providers.ts +269 -0
- package/bash-mode/completion.ts +416 -0
- package/bash-mode/editor-ghost.ts +40 -0
- package/bash-mode/editor-input.ts +80 -0
- package/bash-mode/editor.ts +437 -0
- package/bash-mode/history.ts +263 -0
- package/bash-mode/shell-session.ts +286 -0
- package/bash-mode/transcript.ts +108 -0
- package/bash-mode/types.ts +80 -0
- package/index.ts +6 -0
- package/package.json +55 -0
- package/queue/store.ts +443 -0
- package/queue/types.ts +54 -0
- package/src/config/custom-items.ts +182 -0
- package/src/config/extension-statuses.ts +51 -0
- package/src/config/layout.ts +60 -0
- package/src/config/parse.ts +127 -0
- package/src/config/powerline-config.ts +18 -0
- package/src/config/presets.ts +245 -0
- package/src/config/primitives.ts +117 -0
- package/src/config/segment-ids.ts +114 -0
- package/src/config/segment-options.ts +128 -0
- package/src/config/settings-patch.ts +26 -0
- package/src/config/types.ts +277 -0
- package/src/core/frontmatter.ts +40 -0
- package/src/editor/autocomplete-chain.ts +41 -0
- package/src/extension/activate.ts +28 -0
- package/src/extension/bash-mode-actions.ts +104 -0
- package/src/extension/commands.ts +268 -0
- package/src/extension/constants.ts +46 -0
- package/src/extension/custom-editor.ts +406 -0
- package/src/extension/git-invalidation.ts +40 -0
- package/src/extension/layout.ts +160 -0
- package/src/extension/menu-views.ts +393 -0
- package/src/extension/powerline-widgets.ts +95 -0
- package/src/extension/prompt-history.ts +219 -0
- package/src/extension/queue-commands.ts +245 -0
- package/src/extension/queue-context.ts +12 -0
- package/src/extension/queue-integration.ts +434 -0
- package/src/extension/segment-context.ts +212 -0
- package/src/extension/session-lifecycle.ts +373 -0
- package/src/extension/settings-io.ts +202 -0
- package/src/extension/shortcuts-config.ts +357 -0
- package/src/extension/shortcuts-router.ts +383 -0
- package/src/extension/skills/inline-invocation.ts +174 -0
- package/src/extension/skills/ook.md +6 -0
- package/src/extension/skills/test.md +6 -0
- package/src/extension/stale-context.ts +10 -0
- package/src/extension/stash-history.ts +103 -0
- package/src/extension/state.ts +159 -0
- package/src/extension/status-line-renderers.ts +222 -0
- package/src/extension/types.ts +97 -0
- package/src/extension/vibe-command.ts +160 -0
- package/src/extension/welcome-control.ts +27 -0
- package/src/extension/welcome-integration.ts +153 -0
- package/src/git/status.ts +332 -0
- package/src/paths/agent-dirs.ts +67 -0
- package/src/render/timer.ts +46 -0
- package/src/segments/core.ts +256 -0
- package/src/segments/custom.ts +114 -0
- package/src/segments/index.ts +3 -0
- package/src/segments/registry.ts +87 -0
- package/src/segments/shared.ts +36 -0
- package/src/segments/system.ts +235 -0
- package/src/segments/usage.ts +178 -0
- package/src/shell/cd-command.ts +190 -0
- package/src/shortcuts/matching.ts +61 -0
- package/src/theme/colors.ts +60 -0
- package/src/theme/icons.ts +175 -0
- package/src/theme/separators.ts +41 -0
- package/src/theme/theme.ts +211 -0
- package/src/tools/graph.ts +75 -0
- package/src/tools/patch.ts +179 -0
- package/src/tools/ripgrep.ts +104 -0
- package/src/usage/context.ts +97 -0
- package/src/usage/ledger.ts +293 -0
- package/src/usage/rates.ts +155 -0
- package/src/welcome/auto-dismiss.ts +43 -0
- package/src/welcome/banner.ts +68 -0
- package/src/welcome/discover.ts +234 -0
- package/src/welcome/format.ts +18 -0
- package/src/welcome/index.ts +5 -0
- package/src/welcome/layout.ts +36 -0
- package/src/welcome/overlay.ts +80 -0
- package/src/welcome/renderer.ts +157 -0
- package/src/welcome/sessions.ts +107 -0
- package/src/welcome/types.ts +41 -0
- package/src/welcome/widgets/graph-widget.ts +25 -0
- package/src/welcome/widgets/index.ts +20 -0
- package/src/welcome/widgets/queue-widget.ts +26 -0
- package/src/welcome/widgets/sessions-widget.ts +23 -0
- package/src/welcome/widgets/shortcuts-widget.ts +17 -0
- package/src/welcome/widgets/system-widget.ts +29 -0
- package/src/working-vibes/generate.ts +144 -0
- package/src/working-vibes/index.ts +24 -0
- package/src/working-vibes/manager.ts +198 -0
- package/src/working-vibes/provider.ts +163 -0
- package/src/working-vibes/storage.ts +357 -0
- package/theme.example.json +24 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type KeybindingsManager,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
isKeyRelease,
|
|
7
|
+
matchesKey,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
10
|
+
import { matchesConfiguredShortcut } from "../src/shortcuts/matching.ts";
|
|
11
|
+
import { getOneOffBashCommandContext } from "./completion.ts";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_EDITOR_BOUNDARY_SHORTCUTS,
|
|
14
|
+
droppedPathTextFromInput,
|
|
15
|
+
isCommandUndoShortcut,
|
|
16
|
+
isPrintableInput,
|
|
17
|
+
resetShellHistoryBrowse,
|
|
18
|
+
} from "./editor-input.ts";
|
|
19
|
+
import { overlayGhostSuggestion } from "./editor-ghost.ts";
|
|
20
|
+
import type {
|
|
21
|
+
BashModeEditorOptions,
|
|
22
|
+
GhostSuggestion,
|
|
23
|
+
} from "./types.ts";
|
|
24
|
+
|
|
25
|
+
export class BashModeEditor extends CustomEditor {
|
|
26
|
+
private readonly keybindingsRef: KeybindingsManager;
|
|
27
|
+
private readonly optionsRef: BashModeEditorOptions;
|
|
28
|
+
private wrappedProviderInstalled = false;
|
|
29
|
+
private shellHistoryIndex = -1;
|
|
30
|
+
private shellHistoryItems: string[] = [];
|
|
31
|
+
private shellHistoryDraft = "";
|
|
32
|
+
private promptHistoryDraft: string | null = null;
|
|
33
|
+
private ghost: GhostSuggestion | null = null;
|
|
34
|
+
private ghostAbort: AbortController | null = null;
|
|
35
|
+
private ghostToken = 0;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
tui: any,
|
|
39
|
+
theme: any,
|
|
40
|
+
keybindings: KeybindingsManager,
|
|
41
|
+
options: BashModeEditorOptions,
|
|
42
|
+
) {
|
|
43
|
+
super(tui, theme, keybindings);
|
|
44
|
+
this.keybindingsRef = keybindings;
|
|
45
|
+
this.optionsRef = options;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
49
|
+
super.setAutocompleteProvider(provider);
|
|
50
|
+
this.wrappedProviderInstalled = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
installAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
54
|
+
this.setAutocompleteProvider(provider);
|
|
55
|
+
this.wrappedProviderInstalled = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
hasWrappedProvider(): boolean {
|
|
59
|
+
return this.wrappedProviderInstalled;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getGhostSuggestion(): GhostSuggestion | null {
|
|
63
|
+
return this.isShellCompletionContext() ? this.ghost : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
refreshGhostSuggestion(): void {
|
|
67
|
+
this.scheduleGhostUpdate();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
clearGhostSuggestion(): void {
|
|
71
|
+
this.ghostAbort?.abort();
|
|
72
|
+
this.ghostAbort = null;
|
|
73
|
+
this.ghost = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
dismissBashModeUi(): void {
|
|
77
|
+
resetShellHistoryBrowse(this);
|
|
78
|
+
this.clearGhostSuggestion();
|
|
79
|
+
|
|
80
|
+
const cancelAutocomplete = Reflect.get(this, "cancelAutocomplete");
|
|
81
|
+
if (typeof cancelAutocomplete === "function") {
|
|
82
|
+
cancelAutocomplete.call(this);
|
|
83
|
+
}
|
|
84
|
+
this.tui.requestRender();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
handleInput(data: string): void {
|
|
88
|
+
const droppedPathText = droppedPathTextFromInput(data);
|
|
89
|
+
if (droppedPathText !== null) {
|
|
90
|
+
this.insertTextAtCursor(droppedPathText);
|
|
91
|
+
resetShellHistoryBrowse(this);
|
|
92
|
+
if (this.isShellCompletionContext()) {
|
|
93
|
+
this.scheduleGhostUpdate();
|
|
94
|
+
} else {
|
|
95
|
+
this.clearGhostSuggestion();
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pasteInProgress =
|
|
101
|
+
data.includes("\x1b[200~") || Reflect.get(this, "isInPaste") === true;
|
|
102
|
+
if (pasteInProgress) {
|
|
103
|
+
super.handleInput(data);
|
|
104
|
+
if (Reflect.get(this, "isInPaste") === true) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const bashMode = this.optionsRef.isBashModeActive();
|
|
109
|
+
const oneOffBashCommand = !bashMode && this.isOneOffBashCommandContext();
|
|
110
|
+
|
|
111
|
+
if (isCommandUndoShortcut(data)) {
|
|
112
|
+
const undo = Reflect.get(this, "undo");
|
|
113
|
+
if (typeof undo === "function") {
|
|
114
|
+
undo.call(this);
|
|
115
|
+
}
|
|
116
|
+
resetShellHistoryBrowse(this);
|
|
117
|
+
if (this.isShellCompletionContext()) {
|
|
118
|
+
this.scheduleGhostUpdate();
|
|
119
|
+
} else {
|
|
120
|
+
this.clearGhostSuggestion();
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (bashMode && this.keybindingsRef.matches(data, "app.interrupt")) {
|
|
126
|
+
this.optionsRef.onExitBashMode();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
bashMode &&
|
|
132
|
+
this.keybindingsRef.matches(data, "app.clear") &&
|
|
133
|
+
this.optionsRef.isShellRunning()
|
|
134
|
+
) {
|
|
135
|
+
this.optionsRef.onInterrupt();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
bashMode &&
|
|
141
|
+
this.keybindingsRef.matches(data, "tui.editor.cursorUp")
|
|
142
|
+
) {
|
|
143
|
+
this.navigateShellHistory(-1);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
bashMode &&
|
|
149
|
+
this.keybindingsRef.matches(data, "tui.editor.cursorDown")
|
|
150
|
+
) {
|
|
151
|
+
this.navigateShellHistory(1);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const editorBoundaryShortcuts =
|
|
156
|
+
this.optionsRef.editorBoundaryShortcuts ??
|
|
157
|
+
DEFAULT_EDITOR_BOUNDARY_SHORTCUTS;
|
|
158
|
+
if (
|
|
159
|
+
!isKeyRelease(data) &&
|
|
160
|
+
matchesConfiguredShortcut(data, editorBoundaryShortcuts.start)
|
|
161
|
+
) {
|
|
162
|
+
this.moveCursorToEditorBoundary("start");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
!isKeyRelease(data) &&
|
|
168
|
+
matchesConfiguredShortcut(data, editorBoundaryShortcuts.end)
|
|
169
|
+
) {
|
|
170
|
+
this.moveCursorToEditorBoundary("end");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
(bashMode || oneOffBashCommand) &&
|
|
176
|
+
this.keybindingsRef.matches(data, "tui.input.tab")
|
|
177
|
+
) {
|
|
178
|
+
this.acceptGhostSuggestion();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
(bashMode || oneOffBashCommand) &&
|
|
184
|
+
this.keybindingsRef.matches(data, "tui.editor.cursorRight") &&
|
|
185
|
+
this.acceptGhostSuggestion()
|
|
186
|
+
) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
!bashMode &&
|
|
192
|
+
matchesKey(data, "up") &&
|
|
193
|
+
this.isPromptHistoryRecallPosition()
|
|
194
|
+
) {
|
|
195
|
+
const navigateHistory = Reflect.get(this, "navigateHistory");
|
|
196
|
+
if (typeof navigateHistory === "function") {
|
|
197
|
+
if (Reflect.get(this, "historyIndex") === -1) {
|
|
198
|
+
this.promptHistoryDraft = this.getText();
|
|
199
|
+
}
|
|
200
|
+
navigateHistory.call(this, -1);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
!bashMode &&
|
|
207
|
+
matchesKey(data, "down") &&
|
|
208
|
+
Reflect.get(this, "historyIndex") > -1
|
|
209
|
+
) {
|
|
210
|
+
const isOnLastVisualLine = Reflect.get(this, "isOnLastVisualLine");
|
|
211
|
+
if (
|
|
212
|
+
typeof isOnLastVisualLine !== "function" ||
|
|
213
|
+
isOnLastVisualLine.call(this)
|
|
214
|
+
) {
|
|
215
|
+
const navigateHistory = Reflect.get(this, "navigateHistory");
|
|
216
|
+
if (typeof navigateHistory === "function") {
|
|
217
|
+
navigateHistory.call(this, 1);
|
|
218
|
+
if (
|
|
219
|
+
Reflect.get(this, "historyIndex") === -1 &&
|
|
220
|
+
this.promptHistoryDraft !== null
|
|
221
|
+
) {
|
|
222
|
+
const draft = this.promptHistoryDraft;
|
|
223
|
+
this.promptHistoryDraft = null;
|
|
224
|
+
const setTextInternal = Reflect.get(this, "setTextInternal");
|
|
225
|
+
if (typeof setTextInternal === "function") {
|
|
226
|
+
setTextInternal.call(this, draft);
|
|
227
|
+
} else {
|
|
228
|
+
this.setText(draft);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
bashMode &&
|
|
238
|
+
this.keybindingsRef.matches(data, "tui.input.submit") &&
|
|
239
|
+
!this.keybindingsRef.matches(data, "tui.input.newLine")
|
|
240
|
+
) {
|
|
241
|
+
if (this.optionsRef.isShellRunning()) {
|
|
242
|
+
this.optionsRef.onNotify("Shell command already running", "warning");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const command = this.getExpandedText().trim();
|
|
247
|
+
if (!command) return;
|
|
248
|
+
this.clearGhostSuggestion();
|
|
249
|
+
resetShellHistoryBrowse(this);
|
|
250
|
+
this.optionsRef.onEditorSubmit?.();
|
|
251
|
+
this.optionsRef.onSubmitCommand(command);
|
|
252
|
+
this.setText("");
|
|
253
|
+
this.refreshGhostSuggestion();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
super.handleInput(data);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!this.isShellCompletionContext()) {
|
|
261
|
+
resetShellHistoryBrowse(this);
|
|
262
|
+
this.clearGhostSuggestion();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
pasteInProgress ||
|
|
268
|
+
isPrintableInput(data) ||
|
|
269
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteCharBackward") ||
|
|
270
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteCharForward") ||
|
|
271
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteWordBackward") ||
|
|
272
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteWordForward") ||
|
|
273
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteToLineStart") ||
|
|
274
|
+
this.keybindingsRef.matches(data, "tui.editor.deleteToLineEnd") ||
|
|
275
|
+
this.keybindingsRef.matches(data, "tui.input.newLine") ||
|
|
276
|
+
this.keybindingsRef.matches(data, "tui.editor.cursorLeft") ||
|
|
277
|
+
this.keybindingsRef.matches(data, "tui.editor.cursorRight")
|
|
278
|
+
) {
|
|
279
|
+
resetShellHistoryBrowse(this);
|
|
280
|
+
this.scheduleGhostUpdate();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
render(width: number): string[] {
|
|
285
|
+
const lines = super.render(width);
|
|
286
|
+
if (!this.isShellCompletionContext()) return lines;
|
|
287
|
+
if (!this.ghost) return lines;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
overlayGhostSuggestion(
|
|
291
|
+
lines,
|
|
292
|
+
width,
|
|
293
|
+
this.getText(),
|
|
294
|
+
this.ghost,
|
|
295
|
+
this.getCursor(),
|
|
296
|
+
) ?? lines
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private isShellCompletionContext(): boolean {
|
|
301
|
+
return (
|
|
302
|
+
this.optionsRef.isBashModeActive() || this.isOneOffBashCommandContext()
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private isOneOffBashCommandContext(): boolean {
|
|
307
|
+
return getOneOffBashCommandContext(this.getExpandedText()) !== null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private moveCursorToEditorBoundary(position: "start" | "end"): void {
|
|
311
|
+
const state = Reflect.get(this, "state");
|
|
312
|
+
const lines =
|
|
313
|
+
state && typeof state === "object" ? Reflect.get(state, "lines") : null;
|
|
314
|
+
if (!Array.isArray(lines)) {
|
|
315
|
+
throw new Error("Editor cursor state is unavailable");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (position === "start") {
|
|
319
|
+
Reflect.set(state, "cursorLine", 0);
|
|
320
|
+
Reflect.set(state, "cursorCol", 0);
|
|
321
|
+
} else {
|
|
322
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
323
|
+
Reflect.set(state, "cursorLine", lastLine);
|
|
324
|
+
Reflect.set(
|
|
325
|
+
state,
|
|
326
|
+
"cursorCol",
|
|
327
|
+
typeof lines[lastLine] === "string" ? lines[lastLine].length : 0,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
Reflect.set(this, "lastAction", null);
|
|
332
|
+
Reflect.set(this, "preferredVisualCol", null);
|
|
333
|
+
Reflect.set(this, "snappedFromCursorCol", null);
|
|
334
|
+
this.tui.requestRender();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private acceptGhostSuggestion(): boolean {
|
|
338
|
+
if (!this.ghost) return false;
|
|
339
|
+
const text = this.getExpandedText();
|
|
340
|
+
if (text.includes("\n")) return false;
|
|
341
|
+
|
|
342
|
+
const cursor = this.getCursor();
|
|
343
|
+
if (cursor.line !== 0 || cursor.col !== text.length) return false;
|
|
344
|
+
|
|
345
|
+
if (!this.ghost.value.startsWith(text) || this.ghost.value === text)
|
|
346
|
+
return false;
|
|
347
|
+
this.setText(this.ghost.value);
|
|
348
|
+
this.clearGhostSuggestion();
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private isPromptHistoryRecallPosition(): boolean {
|
|
353
|
+
if (this.isShowingAutocomplete()) return false;
|
|
354
|
+
|
|
355
|
+
const history = Reflect.get(this, "history");
|
|
356
|
+
if (!Array.isArray(history) || history.length === 0) return false;
|
|
357
|
+
|
|
358
|
+
const lines = this.getLines();
|
|
359
|
+
const cursor = this.getCursor();
|
|
360
|
+
if (lines.length === 1) {
|
|
361
|
+
return cursor.line === 0 && cursor.col === (lines[0]?.length ?? 0);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const isOnFirstVisualLine = Reflect.get(this, "isOnFirstVisualLine");
|
|
365
|
+
if (
|
|
366
|
+
typeof isOnFirstVisualLine === "function" &&
|
|
367
|
+
!isOnFirstVisualLine.call(this)
|
|
368
|
+
) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return cursor.line === 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private navigateShellHistory(direction: -1 | 1): void {
|
|
376
|
+
const prefix = this.shellHistoryDraft || this.getExpandedText();
|
|
377
|
+
if (this.shellHistoryIndex === -1) {
|
|
378
|
+
this.shellHistoryDraft = prefix;
|
|
379
|
+
this.shellHistoryItems = this.optionsRef.getHistoryEntries(prefix);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (this.shellHistoryItems.length === 0) {
|
|
383
|
+
this.optionsRef.onNotify("No shell history matches", "info");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (direction < 0) {
|
|
388
|
+
this.shellHistoryIndex = Math.min(
|
|
389
|
+
this.shellHistoryItems.length - 1,
|
|
390
|
+
this.shellHistoryIndex + 1,
|
|
391
|
+
);
|
|
392
|
+
this.setText(
|
|
393
|
+
this.shellHistoryItems[this.shellHistoryIndex] ??
|
|
394
|
+
this.shellHistoryDraft,
|
|
395
|
+
);
|
|
396
|
+
this.clearGhostSuggestion();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.shellHistoryIndex -= 1;
|
|
401
|
+
if (this.shellHistoryIndex < 0) {
|
|
402
|
+
this.shellHistoryIndex = -1;
|
|
403
|
+
this.setText(this.shellHistoryDraft);
|
|
404
|
+
this.scheduleGhostUpdate();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.setText(
|
|
409
|
+
this.shellHistoryItems[this.shellHistoryIndex] ?? this.shellHistoryDraft,
|
|
410
|
+
);
|
|
411
|
+
this.clearGhostSuggestion();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private scheduleGhostUpdate(): void {
|
|
415
|
+
const text = this.getExpandedText();
|
|
416
|
+
const currentToken = ++this.ghostToken;
|
|
417
|
+
this.ghostAbort?.abort();
|
|
418
|
+
|
|
419
|
+
const controller = new AbortController();
|
|
420
|
+
this.ghostAbort = controller;
|
|
421
|
+
this.optionsRef
|
|
422
|
+
.resolveGhostSuggestion(text, controller.signal)
|
|
423
|
+
.then((ghost) => {
|
|
424
|
+
if (controller.signal.aborted || currentToken !== this.ghostToken)
|
|
425
|
+
return;
|
|
426
|
+
this.ghost = ghost;
|
|
427
|
+
this.tui.requestRender();
|
|
428
|
+
})
|
|
429
|
+
.catch((error) => {
|
|
430
|
+
if (error instanceof Error && error.message === "aborted") return;
|
|
431
|
+
console.debug(
|
|
432
|
+
"[powerline-footer] Failed to resolve bash ghost suggestion:",
|
|
433
|
+
error,
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
statSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { getAgentPath, getHomeDir } from "../src/paths/agent-dirs.ts";
|
|
10
|
+
|
|
11
|
+
interface PersistedHistoryEntry {
|
|
12
|
+
command: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// mode and ctimeMs are part of the fingerprint so permission/ACL transitions
|
|
18
|
+
// (e.g. chmod 000) invalidate the cache even when content is unchanged.
|
|
19
|
+
interface FileFingerprint {
|
|
20
|
+
ctimeMs: number;
|
|
21
|
+
mtimeMs: number;
|
|
22
|
+
mode: number;
|
|
23
|
+
size: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CachedHistory<T> extends FileFingerprint {
|
|
27
|
+
entries: T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesFingerprint(
|
|
31
|
+
cached: FileFingerprint,
|
|
32
|
+
stat: FileFingerprint,
|
|
33
|
+
): boolean {
|
|
34
|
+
return (
|
|
35
|
+
cached.ctimeMs === stat.ctimeMs &&
|
|
36
|
+
cached.mtimeMs === stat.mtimeMs &&
|
|
37
|
+
cached.mode === stat.mode &&
|
|
38
|
+
cached.size === stat.size
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const projectHistoryCache = new Map<
|
|
43
|
+
string,
|
|
44
|
+
CachedHistory<PersistedHistoryEntry[]>
|
|
45
|
+
>();
|
|
46
|
+
const globalHistoryCache = new Map<string, CachedHistory<string[]>>();
|
|
47
|
+
|
|
48
|
+
function getHistoryDir(): string {
|
|
49
|
+
return getAgentPath("powerline-footer", "bash-history");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function projectKey(cwd: string): string {
|
|
53
|
+
return cwd.replace(/^[/\\]+|[/\\]+$/g, "").replace(/[\\/]+/g, "-") || "root";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function projectHistoryPath(cwd: string): string {
|
|
57
|
+
return join(getHistoryDir(), `${projectKey(cwd)}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizePersistedEntries(value: unknown): PersistedHistoryEntry[] {
|
|
61
|
+
if (!Array.isArray(value)) return [];
|
|
62
|
+
|
|
63
|
+
const entries: PersistedHistoryEntry[] = [];
|
|
64
|
+
for (const entry of value) {
|
|
65
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
66
|
+
continue;
|
|
67
|
+
const command =
|
|
68
|
+
typeof entry.command === "string" ? entry.command.trim() : "";
|
|
69
|
+
const cwd = typeof entry.cwd === "string" ? entry.cwd.trim() : "";
|
|
70
|
+
const timestamp =
|
|
71
|
+
typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)
|
|
72
|
+
? entry.timestamp
|
|
73
|
+
: 0;
|
|
74
|
+
if (!command || !cwd || !timestamp) continue;
|
|
75
|
+
entries.push({ command, cwd, timestamp });
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readProjectHistory(cwd: string): PersistedHistoryEntry[] {
|
|
81
|
+
const filePath = projectHistoryPath(cwd);
|
|
82
|
+
if (!existsSync(filePath)) {
|
|
83
|
+
projectHistoryCache.delete(filePath);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const stat = statSync(filePath);
|
|
89
|
+
const cached = projectHistoryCache.get(filePath);
|
|
90
|
+
if (cached && matchesFingerprint(cached, stat)) {
|
|
91
|
+
return cached.entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
95
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
96
|
+
return [];
|
|
97
|
+
const entries = normalizePersistedEntries(
|
|
98
|
+
(parsed as { entries?: unknown }).entries,
|
|
99
|
+
).sort((a, b) => b.timestamp - a.timestamp);
|
|
100
|
+
projectHistoryCache.set(filePath, {
|
|
101
|
+
ctimeMs: stat.ctimeMs,
|
|
102
|
+
mtimeMs: stat.mtimeMs,
|
|
103
|
+
mode: stat.mode,
|
|
104
|
+
size: stat.size,
|
|
105
|
+
entries,
|
|
106
|
+
});
|
|
107
|
+
return entries;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Project history is a best-effort cache. If it is unreadable or malformed,
|
|
110
|
+
// bash mode should keep working instead of failing command entry entirely.
|
|
111
|
+
console.debug(
|
|
112
|
+
`[powerline-footer] Failed to read bash project history from ${filePath}:`,
|
|
113
|
+
error,
|
|
114
|
+
);
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function appendProjectHistory(
|
|
120
|
+
cwd: string,
|
|
121
|
+
command: string,
|
|
122
|
+
entryCwd: string,
|
|
123
|
+
): void {
|
|
124
|
+
const normalizedCommand = command.trim();
|
|
125
|
+
if (!normalizedCommand) return;
|
|
126
|
+
|
|
127
|
+
const existing = readProjectHistory(cwd);
|
|
128
|
+
const next: PersistedHistoryEntry[] = [
|
|
129
|
+
{ command: normalizedCommand, cwd: entryCwd, timestamp: Date.now() },
|
|
130
|
+
...existing.filter((entry) => entry.command !== normalizedCommand),
|
|
131
|
+
]
|
|
132
|
+
.slice(0, 500)
|
|
133
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
134
|
+
|
|
135
|
+
const filePath = projectHistoryPath(cwd);
|
|
136
|
+
projectHistoryCache.delete(filePath);
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
139
|
+
writeFileSync(
|
|
140
|
+
filePath,
|
|
141
|
+
JSON.stringify({ version: 1, entries: next }, null, 2) + "\n",
|
|
142
|
+
);
|
|
143
|
+
const stat = statSync(filePath);
|
|
144
|
+
projectHistoryCache.set(filePath, {
|
|
145
|
+
ctimeMs: stat.ctimeMs,
|
|
146
|
+
mtimeMs: stat.mtimeMs,
|
|
147
|
+
mode: stat.mode,
|
|
148
|
+
size: stat.size,
|
|
149
|
+
entries: next,
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// History persistence should never block a successful shell command from completing.
|
|
153
|
+
console.debug(
|
|
154
|
+
`[powerline-footer] Failed to persist bash project history to ${filePath}:`,
|
|
155
|
+
error,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseZshHistoryLine(line: string): string | null {
|
|
161
|
+
const trimmed = line.trim();
|
|
162
|
+
if (!trimmed) return null;
|
|
163
|
+
if (!trimmed.startsWith(":")) return trimmed;
|
|
164
|
+
const parts = trimmed.split(";");
|
|
165
|
+
if (parts.length < 2) return null;
|
|
166
|
+
return parts.slice(1).join(";").trim() || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseBashHistory(lines: string[]): string[] {
|
|
170
|
+
return lines.map((line) => line.trim()).filter(Boolean);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseFishHistory(raw: string): string[] {
|
|
174
|
+
const matches = raw.matchAll(/^\s*-\s*cmd:\s*(.+)$/gm);
|
|
175
|
+
const commands: string[] = [];
|
|
176
|
+
for (const match of matches) {
|
|
177
|
+
const command = match[1]?.trim();
|
|
178
|
+
if (command) commands.push(command);
|
|
179
|
+
}
|
|
180
|
+
return commands;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function readGlobalShellHistory(shellPath: string): string[] {
|
|
184
|
+
const shellName = shellPath.split("/").pop()?.toLowerCase() ?? "";
|
|
185
|
+
const home = getHomeDir();
|
|
186
|
+
const filePath = shellName.includes("fish")
|
|
187
|
+
? join(home, ".local", "share", "fish", "fish_history")
|
|
188
|
+
: process.env.HISTFILE ||
|
|
189
|
+
join(home, shellName.includes("zsh") ? ".zsh_history" : ".bash_history");
|
|
190
|
+
const cacheKey = `${shellName}\0${filePath}`;
|
|
191
|
+
|
|
192
|
+
if (!existsSync(filePath)) {
|
|
193
|
+
globalHistoryCache.delete(cacheKey);
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let stat: FileFingerprint | undefined;
|
|
198
|
+
try {
|
|
199
|
+
stat = statSync(filePath);
|
|
200
|
+
const cached = globalHistoryCache.get(cacheKey);
|
|
201
|
+
if (cached && matchesFingerprint(cached, stat)) {
|
|
202
|
+
return cached.entries;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const raw = readFileSync(filePath, "utf8");
|
|
206
|
+
const entries = shellName.includes("zsh")
|
|
207
|
+
? raw
|
|
208
|
+
.split("\n")
|
|
209
|
+
.map(parseZshHistoryLine)
|
|
210
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
211
|
+
.reverse()
|
|
212
|
+
: shellName.includes("fish")
|
|
213
|
+
? parseFishHistory(raw).reverse()
|
|
214
|
+
: parseBashHistory(raw.split("\n")).reverse();
|
|
215
|
+
globalHistoryCache.set(cacheKey, {
|
|
216
|
+
ctimeMs: stat.ctimeMs,
|
|
217
|
+
mtimeMs: stat.mtimeMs,
|
|
218
|
+
mode: stat.mode,
|
|
219
|
+
size: stat.size,
|
|
220
|
+
entries,
|
|
221
|
+
});
|
|
222
|
+
return entries;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// Global shell history is optional recall data. If the file exists but is
|
|
225
|
+
// unreadable, cache an empty result under its fingerprint so bash mode keeps
|
|
226
|
+
// working without logging a stack on every keypress until the file changes.
|
|
227
|
+
// Only cache persistent permission failures. Transient filesystem errors
|
|
228
|
+
// must be retried on the next completion rather than hiding history.
|
|
229
|
+
const errorCode =
|
|
230
|
+
error && typeof error === "object" && "code" in error
|
|
231
|
+
? (error as { code?: string }).code
|
|
232
|
+
: undefined;
|
|
233
|
+
if (stat && (errorCode === "EACCES" || errorCode === "EPERM")) {
|
|
234
|
+
globalHistoryCache.set(cacheKey, { ...stat, entries: [] });
|
|
235
|
+
}
|
|
236
|
+
console.debug(
|
|
237
|
+
`[powerline-footer] Failed to read global shell history for ${shellName}:`,
|
|
238
|
+
error,
|
|
239
|
+
);
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function matchHistoryEntries(
|
|
245
|
+
entries: string[],
|
|
246
|
+
prefix: string,
|
|
247
|
+
limit: number,
|
|
248
|
+
): string[] {
|
|
249
|
+
const trimmedPrefix = prefix.trim();
|
|
250
|
+
const seen = new Set<string>();
|
|
251
|
+
const matches: string[] = [];
|
|
252
|
+
|
|
253
|
+
for (const rawEntry of entries) {
|
|
254
|
+
const entry = rawEntry?.trim();
|
|
255
|
+
if (!entry || seen.has(entry)) continue;
|
|
256
|
+
if (trimmedPrefix && !entry.startsWith(trimmedPrefix)) continue;
|
|
257
|
+
seen.add(entry);
|
|
258
|
+
matches.push(entry);
|
|
259
|
+
if (matches.length >= limit) break;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return matches;
|
|
263
|
+
}
|