pi-cc-extensions 0.1.1

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.
Files changed (39) hide show
  1. package/README.md +141 -0
  2. package/assets/readme/context-preview.webp +0 -0
  3. package/assets/readme/context-usage.webp +0 -0
  4. package/assets/readme/hero.svg +101 -0
  5. package/assets/readme/session-reference.webp +0 -0
  6. package/assets/readme/showcase.webp +0 -0
  7. package/extensions/README.md +3 -0
  8. package/extensions/ccstyle-powerline/bash-mode/completion.ts +556 -0
  9. package/extensions/ccstyle-powerline/bash-mode/editor.ts +442 -0
  10. package/extensions/ccstyle-powerline/bash-mode/history.ts +151 -0
  11. package/extensions/ccstyle-powerline/bash-mode/shell-session.ts +286 -0
  12. package/extensions/ccstyle-powerline/bash-mode/transcript.ts +108 -0
  13. package/extensions/ccstyle-powerline/bash-mode/types.ts +59 -0
  14. package/extensions/ccstyle-powerline/colors.ts +69 -0
  15. package/extensions/ccstyle-powerline/context-usage.ts +46 -0
  16. package/extensions/ccstyle-powerline/fixed-editor/cluster.ts +107 -0
  17. package/extensions/ccstyle-powerline/fixed-editor/terminal-split.ts +1317 -0
  18. package/extensions/ccstyle-powerline/git-status.ts +218 -0
  19. package/extensions/ccstyle-powerline/icons.ts +181 -0
  20. package/extensions/ccstyle-powerline/powerline-config.ts +234 -0
  21. package/extensions/ccstyle-powerline/powerline.ts +3150 -0
  22. package/extensions/ccstyle-powerline/presets.ts +109 -0
  23. package/extensions/ccstyle-powerline/render-scheduler.ts +24 -0
  24. package/extensions/ccstyle-powerline/segments.ts +476 -0
  25. package/extensions/ccstyle-powerline/separators.ts +41 -0
  26. package/extensions/ccstyle-powerline/shortcuts.ts +57 -0
  27. package/extensions/ccstyle-powerline/theme.json +26 -0
  28. package/extensions/ccstyle-powerline/theme.ts +241 -0
  29. package/extensions/ccstyle-powerline/types.ts +197 -0
  30. package/extensions/ccstyle-powerline/welcome-dismiss.ts +34 -0
  31. package/extensions/ccstyle-powerline/welcome.ts +611 -0
  32. package/extensions/ccstyle-powerline/working-vibes.ts +695 -0
  33. package/extensions/claude-code-style.ts +987 -0
  34. package/extensions/context.ts +362 -0
  35. package/extensions/session-reference/README.md +28 -0
  36. package/extensions/session-reference/core.test.ts +74 -0
  37. package/extensions/session-reference/core.ts +258 -0
  38. package/extensions/session-reference/index.ts +379 -0
  39. package/package.json +33 -0
@@ -0,0 +1,442 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
3
+ import { isKeyRelease, matchesKey, visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent/dist/core/keybindings.js";
5
+ import type { AutocompleteProvider } from "@earendil-works/pi-tui";
6
+ import { matchesConfiguredShortcut } from "../shortcuts.ts";
7
+ import { getOneOffBashCommandContext } from "./completion.ts";
8
+ import type { GhostSuggestion } from "./types.ts";
9
+
10
+ interface EditorBoundaryShortcuts {
11
+ start: string;
12
+ end: string;
13
+ }
14
+
15
+ interface BashModeEditorOptions {
16
+ keybindings: KeybindingsManager;
17
+ isBashModeActive: () => boolean;
18
+ isShellRunning: () => boolean;
19
+ onExitBashMode: () => void;
20
+ onSubmitCommand: (command: string) => void;
21
+ onEditorSubmit?: () => void;
22
+ editorBoundaryShortcuts?: EditorBoundaryShortcuts;
23
+ onInterrupt: () => void;
24
+ onNotify: (message: string, level?: "info" | "warning" | "error") => void;
25
+ getHistoryEntries: (prefix: string) => string[];
26
+ resolveGhostSuggestion: (text: string, signal: AbortSignal) => Promise<GhostSuggestion | null>;
27
+ }
28
+
29
+ const DEFAULT_EDITOR_BOUNDARY_SHORTCUTS: EditorBoundaryShortcuts = {
30
+ start: "super+shift+up",
31
+ end: "super+shift+down",
32
+ };
33
+
34
+ function isPrintableInput(data: string): boolean {
35
+ return data.length === 1 && data.charCodeAt(0) >= 32;
36
+ }
37
+
38
+ function isCommandUndoShortcut(data: string): boolean {
39
+ return data === "\x1b[122;9u"
40
+ || data === "\x1b[122;9:1u"
41
+ || data === "\x1b[122;9:2u"
42
+ || data === "\x1b[27;9;122~";
43
+ }
44
+
45
+ function bracketedPasteContent(data: string): string | null {
46
+ const startMarker = "\x1b[200~";
47
+ const endMarker = "\x1b[201~";
48
+ const start = data.indexOf(startMarker);
49
+ if (start !== 0) return null;
50
+
51
+ const end = data.indexOf(endMarker, startMarker.length);
52
+ if (end === -1 || end + endMarker.length !== data.length) return null;
53
+
54
+ return data.slice(startMarker.length, end);
55
+ }
56
+
57
+ function decodeFileUriList(text: string): string | null {
58
+ const entries = text
59
+ .split(/\r?\n|\s+/)
60
+ .map((entry) => entry.trim())
61
+ .filter((entry) => entry.length > 0 && !entry.startsWith("#"));
62
+
63
+ if (entries.length === 0 || entries.some((entry) => !entry.startsWith("file://"))) {
64
+ return null;
65
+ }
66
+
67
+ try {
68
+ return entries.map((entry) => fileURLToPath(entry)).join(" ");
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function droppedPathTextFromInput(data: string): string | null {
75
+ const pasteContent = bracketedPasteContent(data);
76
+ const text = pasteContent ?? data;
77
+ const uriList = decodeFileUriList(text);
78
+ if (uriList) return uriList;
79
+
80
+ const trimmed = text.replace(/^[\r\n]+|[\r\n]+$/g, "");
81
+ if (trimmed.length <= 1 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(trimmed)) {
82
+ return null;
83
+ }
84
+
85
+ if (/^(?:\/|~\/|\.\.?\/)/.test(trimmed) && !/[\r\n]/.test(trimmed)) {
86
+ return trimmed;
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ export class BashModeEditor extends CustomEditor {
93
+ private readonly keybindingsRef: KeybindingsManager;
94
+ private readonly optionsRef: BashModeEditorOptions;
95
+ private wrappedProviderInstalled = false;
96
+ private shellHistoryIndex = -1;
97
+ private shellHistoryItems: string[] = [];
98
+ private shellHistoryDraft = "";
99
+ private promptHistoryDraft: string | null = null;
100
+ private ghost: GhostSuggestion | null = null;
101
+ private ghostAbort: AbortController | null = null;
102
+ private ghostToken = 0;
103
+
104
+ constructor(tui: any, theme: any, keybindings: KeybindingsManager, options: BashModeEditorOptions) {
105
+ super(tui, theme, keybindings);
106
+ this.keybindingsRef = keybindings;
107
+ this.optionsRef = options;
108
+ }
109
+
110
+ installAutocompleteProvider(provider: AutocompleteProvider): void {
111
+ this.setAutocompleteProvider(provider);
112
+ this.wrappedProviderInstalled = true;
113
+ }
114
+
115
+ hasWrappedProvider(): boolean {
116
+ return this.wrappedProviderInstalled;
117
+ }
118
+
119
+ getGhostSuggestion(): GhostSuggestion | null {
120
+ return this.isShellCompletionContext() ? this.ghost : null;
121
+ }
122
+
123
+ refreshGhostSuggestion(): void {
124
+ this.scheduleGhostUpdate();
125
+ }
126
+
127
+ clearGhostSuggestion(): void {
128
+ this.ghostAbort?.abort();
129
+ this.ghostAbort = null;
130
+ this.ghost = null;
131
+ }
132
+
133
+ dismissBashModeUi(): void {
134
+ this.shellHistoryIndex = -1;
135
+ this.shellHistoryItems = [];
136
+ this.shellHistoryDraft = "";
137
+ this.clearGhostSuggestion();
138
+
139
+ if ("cancelAutocomplete" in this && typeof this.cancelAutocomplete === "function") {
140
+ this.cancelAutocomplete();
141
+ }
142
+ this.tui.requestRender();
143
+ }
144
+
145
+ handleInput(data: string): void {
146
+ const droppedPathText = droppedPathTextFromInput(data);
147
+ if (droppedPathText !== null) {
148
+ this.insertTextAtCursor(droppedPathText);
149
+ this.shellHistoryIndex = -1;
150
+ this.shellHistoryItems = [];
151
+ this.shellHistoryDraft = "";
152
+ if (this.isShellCompletionContext()) {
153
+ this.scheduleGhostUpdate();
154
+ } else {
155
+ this.clearGhostSuggestion();
156
+ }
157
+ return;
158
+ }
159
+
160
+ const pasteInProgress = data.includes("\x1b[200~") || Reflect.get(this, "isInPaste") === true;
161
+ if (pasteInProgress) {
162
+ super.handleInput(data);
163
+ if (Reflect.get(this, "isInPaste") === true) {
164
+ return;
165
+ }
166
+ } else {
167
+ const bashMode = this.optionsRef.isBashModeActive();
168
+ const oneOffBashCommand = !bashMode && this.isOneOffBashCommandContext();
169
+
170
+ if (isCommandUndoShortcut(data)) {
171
+ this.undo();
172
+ this.shellHistoryIndex = -1;
173
+ this.shellHistoryItems = [];
174
+ this.shellHistoryDraft = "";
175
+ if (this.isShellCompletionContext()) {
176
+ this.scheduleGhostUpdate();
177
+ } else {
178
+ this.clearGhostSuggestion();
179
+ }
180
+ return;
181
+ }
182
+
183
+ if (bashMode && this.keybindingsRef.matches(data, "app.interrupt")) {
184
+ this.optionsRef.onExitBashMode();
185
+ return;
186
+ }
187
+
188
+ if (bashMode && this.keybindingsRef.matches(data, "app.clear") && this.optionsRef.isShellRunning()) {
189
+ this.optionsRef.onInterrupt();
190
+ return;
191
+ }
192
+
193
+ if (bashMode && this.keybindingsRef.matches(data, "tui.editor.cursorUp")) {
194
+ this.navigateShellHistory(-1);
195
+ return;
196
+ }
197
+
198
+ if (bashMode && this.keybindingsRef.matches(data, "tui.editor.cursorDown")) {
199
+ this.navigateShellHistory(1);
200
+ return;
201
+ }
202
+
203
+ const editorBoundaryShortcuts = this.optionsRef.editorBoundaryShortcuts ?? DEFAULT_EDITOR_BOUNDARY_SHORTCUTS;
204
+ if (!isKeyRelease(data) && matchesConfiguredShortcut(data, editorBoundaryShortcuts.start)) {
205
+ this.moveCursorToEditorBoundary("start");
206
+ return;
207
+ }
208
+
209
+ if (!isKeyRelease(data) && matchesConfiguredShortcut(data, editorBoundaryShortcuts.end)) {
210
+ this.moveCursorToEditorBoundary("end");
211
+ return;
212
+ }
213
+
214
+ if ((bashMode || oneOffBashCommand) && this.keybindingsRef.matches(data, "tui.input.tab")) {
215
+ this.acceptGhostSuggestion();
216
+ return;
217
+ }
218
+
219
+ if (
220
+ (bashMode || oneOffBashCommand)
221
+ && this.keybindingsRef.matches(data, "tui.editor.cursorRight")
222
+ && this.acceptGhostSuggestion()
223
+ ) {
224
+ return;
225
+ }
226
+
227
+ if (!bashMode && this.keybindingsRef.matches(data, "tui.editor.cursorUp") && this.isPromptHistoryRecallPosition()) {
228
+ const navigateHistory = Reflect.get(this, "navigateHistory");
229
+ if (typeof navigateHistory === "function") {
230
+ if (Reflect.get(this, "historyIndex") === -1) {
231
+ this.promptHistoryDraft = this.getText();
232
+ }
233
+ navigateHistory.call(this, -1);
234
+ return;
235
+ }
236
+ }
237
+
238
+ if (!bashMode && this.keybindingsRef.matches(data, "tui.editor.cursorDown") && Reflect.get(this, "historyIndex") > -1) {
239
+ const isOnLastVisualLine = Reflect.get(this, "isOnLastVisualLine");
240
+ if (typeof isOnLastVisualLine !== "function" || isOnLastVisualLine.call(this)) {
241
+ const navigateHistory = Reflect.get(this, "navigateHistory");
242
+ if (typeof navigateHistory === "function") {
243
+ navigateHistory.call(this, 1);
244
+ if (Reflect.get(this, "historyIndex") === -1 && this.promptHistoryDraft !== null) {
245
+ const draft = this.promptHistoryDraft;
246
+ this.promptHistoryDraft = null;
247
+ const setTextInternal = Reflect.get(this, "setTextInternal");
248
+ if (typeof setTextInternal === "function") {
249
+ setTextInternal.call(this, draft);
250
+ } else {
251
+ this.setText(draft);
252
+ }
253
+ }
254
+ return;
255
+ }
256
+ }
257
+ }
258
+
259
+ if (bashMode && this.keybindingsRef.matches(data, "tui.input.submit") && !this.keybindingsRef.matches(data, "tui.input.newLine")) {
260
+ if (this.optionsRef.isShellRunning()) {
261
+ this.optionsRef.onNotify("Shell command already running", "warning");
262
+ return;
263
+ }
264
+
265
+ const command = this.getExpandedText().trim();
266
+ if (!command) return;
267
+ this.clearGhostSuggestion();
268
+ this.shellHistoryIndex = -1;
269
+ this.shellHistoryItems = [];
270
+ this.shellHistoryDraft = "";
271
+ this.optionsRef.onEditorSubmit?.();
272
+ this.optionsRef.onSubmitCommand(command);
273
+ this.setText("");
274
+ this.refreshGhostSuggestion();
275
+ return;
276
+ }
277
+
278
+ super.handleInput(data);
279
+ }
280
+
281
+ if (!this.isShellCompletionContext()) {
282
+ this.shellHistoryIndex = -1;
283
+ this.shellHistoryItems = [];
284
+ this.shellHistoryDraft = "";
285
+ this.clearGhostSuggestion();
286
+ return;
287
+ }
288
+
289
+ if (
290
+ pasteInProgress
291
+ ||
292
+ isPrintableInput(data)
293
+ || this.keybindingsRef.matches(data, "tui.editor.deleteCharBackward")
294
+ || this.keybindingsRef.matches(data, "tui.editor.deleteCharForward")
295
+ || this.keybindingsRef.matches(data, "tui.editor.deleteWordBackward")
296
+ || this.keybindingsRef.matches(data, "tui.editor.deleteWordForward")
297
+ || this.keybindingsRef.matches(data, "tui.editor.deleteToLineStart")
298
+ || this.keybindingsRef.matches(data, "tui.editor.deleteToLineEnd")
299
+ || this.keybindingsRef.matches(data, "tui.input.newLine")
300
+ || this.keybindingsRef.matches(data, "tui.editor.cursorLeft")
301
+ || this.keybindingsRef.matches(data, "tui.editor.cursorRight")
302
+ ) {
303
+ this.shellHistoryIndex = -1;
304
+ this.shellHistoryItems = [];
305
+ this.shellHistoryDraft = "";
306
+ this.scheduleGhostUpdate();
307
+ }
308
+ }
309
+
310
+ render(width: number): string[] {
311
+ const lines = super.render(width);
312
+ if (!this.isShellCompletionContext()) return lines;
313
+ if (!this.ghost) return lines;
314
+
315
+ const text = this.getText();
316
+ if (text.includes("\n")) return lines;
317
+ const cursor = this.getCursor();
318
+ if (cursor.line !== 0 || cursor.col !== text.length) return lines;
319
+ if (!this.ghost.value.startsWith(text) || this.ghost.value === text) return lines;
320
+ if (lines.length < 3) return lines;
321
+
322
+ const suffix = this.ghost.value.slice(text.length);
323
+ const contentLine = 1;
324
+ const cursorBlock = "\x1b[7m \x1b[0m";
325
+ const availableWidth = Math.max(0, width - visibleWidth(text) - 1);
326
+ if (availableWidth === 0) return lines;
327
+
328
+ const shownSuffix = truncateToWidth(suffix, availableWidth, "", true);
329
+ if (!shownSuffix) return lines;
330
+
331
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(text) - 1 - visibleWidth(shownSuffix)));
332
+ const ghost = `\x1b[38;5;244m${shownSuffix}\x1b[0m`;
333
+ lines[contentLine] = `${text}${cursorBlock}${ghost}${padding}`;
334
+ return lines;
335
+ }
336
+
337
+ private isShellCompletionContext(): boolean {
338
+ return this.optionsRef.isBashModeActive() || this.isOneOffBashCommandContext();
339
+ }
340
+
341
+ private isOneOffBashCommandContext(): boolean {
342
+ return getOneOffBashCommandContext(this.getExpandedText()) !== null;
343
+ }
344
+
345
+ private moveCursorToEditorBoundary(position: "start" | "end"): void {
346
+ const state = Reflect.get(this, "state");
347
+ const lines = state && typeof state === "object" ? Reflect.get(state, "lines") : null;
348
+ if (!Array.isArray(lines)) {
349
+ throw new Error("Editor cursor state is unavailable");
350
+ }
351
+
352
+ if (position === "start") {
353
+ Reflect.set(state, "cursorLine", 0);
354
+ Reflect.set(state, "cursorCol", 0);
355
+ } else {
356
+ const lastLine = Math.max(0, lines.length - 1);
357
+ Reflect.set(state, "cursorLine", lastLine);
358
+ Reflect.set(state, "cursorCol", typeof lines[lastLine] === "string" ? lines[lastLine].length : 0);
359
+ }
360
+
361
+ Reflect.set(this, "lastAction", null);
362
+ Reflect.set(this, "preferredVisualCol", null);
363
+ Reflect.set(this, "snappedFromCursorCol", null);
364
+ this.tui.requestRender();
365
+ }
366
+
367
+ private acceptGhostSuggestion(): boolean {
368
+ if (!this.ghost) return false;
369
+ const text = this.getExpandedText();
370
+ if (text.includes("\n")) return false;
371
+
372
+ const cursor = this.getCursor();
373
+ if (cursor.line !== 0 || cursor.col !== text.length) return false;
374
+
375
+ if (!this.ghost.value.startsWith(text) || this.ghost.value === text) return false;
376
+ this.setText(this.ghost.value);
377
+ this.clearGhostSuggestion();
378
+ return true;
379
+ }
380
+
381
+ private isPromptHistoryRecallPosition(): boolean {
382
+ if (this.isShowingAutocomplete()) return false;
383
+
384
+ const history = Reflect.get(this, "history");
385
+ if (!Array.isArray(history) || history.length === 0) return false;
386
+
387
+ const lines = this.getLines();
388
+ const cursor = this.getCursor();
389
+ const lastLine = Math.max(0, lines.length - 1);
390
+ return cursor.line === lastLine && cursor.col === (lines[lastLine]?.length ?? 0);
391
+ }
392
+
393
+ private navigateShellHistory(direction: -1 | 1): void {
394
+ const prefix = this.shellHistoryDraft || this.getExpandedText();
395
+ if (this.shellHistoryIndex === -1) {
396
+ this.shellHistoryDraft = prefix;
397
+ this.shellHistoryItems = this.optionsRef.getHistoryEntries(prefix);
398
+ }
399
+
400
+ if (this.shellHistoryItems.length === 0) {
401
+ this.optionsRef.onNotify("No shell history matches", "info");
402
+ return;
403
+ }
404
+
405
+ if (direction < 0) {
406
+ this.shellHistoryIndex = Math.min(this.shellHistoryItems.length - 1, this.shellHistoryIndex + 1);
407
+ this.setText(this.shellHistoryItems[this.shellHistoryIndex] ?? this.shellHistoryDraft);
408
+ this.clearGhostSuggestion();
409
+ return;
410
+ }
411
+
412
+ this.shellHistoryIndex -= 1;
413
+ if (this.shellHistoryIndex < 0) {
414
+ this.shellHistoryIndex = -1;
415
+ this.setText(this.shellHistoryDraft);
416
+ this.scheduleGhostUpdate();
417
+ return;
418
+ }
419
+
420
+ this.setText(this.shellHistoryItems[this.shellHistoryIndex] ?? this.shellHistoryDraft);
421
+ this.clearGhostSuggestion();
422
+ }
423
+
424
+ private scheduleGhostUpdate(): void {
425
+ const text = this.getExpandedText();
426
+ const currentToken = ++this.ghostToken;
427
+ this.ghostAbort?.abort();
428
+
429
+ const controller = new AbortController();
430
+ this.ghostAbort = controller;
431
+ this.optionsRef.resolveGhostSuggestion(text, controller.signal)
432
+ .then((ghost) => {
433
+ if (controller.signal.aborted || currentToken !== this.ghostToken) return;
434
+ this.ghost = ghost;
435
+ this.tui.requestRender();
436
+ })
437
+ .catch((error) => {
438
+ if (error instanceof Error && error.message === "aborted") return;
439
+ console.debug("[powerline-footer] Failed to resolve bash ghost suggestion:", error);
440
+ });
441
+ }
442
+ }
@@ -0,0 +1,151 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ interface PersistedHistoryEntry {
6
+ command: string;
7
+ cwd: string;
8
+ timestamp: number;
9
+ }
10
+
11
+ function getHomeDir(): string {
12
+ return process.env.HOME || process.env.USERPROFILE || homedir();
13
+ }
14
+
15
+ function getHistoryDir(): string {
16
+ return join(getHomeDir(), ".pi", "agent", "powerline-footer", "bash-history");
17
+ }
18
+
19
+ function projectKey(cwd: string): string {
20
+ return cwd.replace(/^[/\\]+|[/\\]+$/g, "").replace(/[\\/]+/g, "-") || "root";
21
+ }
22
+
23
+ function projectHistoryPath(cwd: string): string {
24
+ return join(getHistoryDir(), `${projectKey(cwd)}.json`);
25
+ }
26
+
27
+ function normalizePersistedEntries(value: unknown): PersistedHistoryEntry[] {
28
+ if (!Array.isArray(value)) return [];
29
+
30
+ const entries: PersistedHistoryEntry[] = [];
31
+ for (const entry of value) {
32
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
33
+ const command = typeof entry.command === "string" ? entry.command.trim() : "";
34
+ const cwd = typeof entry.cwd === "string" ? entry.cwd.trim() : "";
35
+ const timestamp = typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)
36
+ ? entry.timestamp
37
+ : 0;
38
+ if (!command || !cwd || !timestamp) continue;
39
+ entries.push({ command, cwd, timestamp });
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ export function readProjectHistory(cwd: string): PersistedHistoryEntry[] {
45
+ const filePath = projectHistoryPath(cwd);
46
+ if (!existsSync(filePath)) return [];
47
+
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
50
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
51
+ return normalizePersistedEntries((parsed as { entries?: unknown }).entries)
52
+ .sort((a, b) => b.timestamp - a.timestamp);
53
+ } catch (error) {
54
+ // Project history is a best-effort cache. If it is unreadable or malformed,
55
+ // bash mode should keep working instead of failing command entry entirely.
56
+ console.debug(`[powerline-footer] Failed to read bash project history from ${filePath}:`, error);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ export function appendProjectHistory(cwd: string, command: string, entryCwd: string): void {
62
+ const normalizedCommand = command.trim();
63
+ if (!normalizedCommand) return;
64
+
65
+ const existing = readProjectHistory(cwd);
66
+ const next: PersistedHistoryEntry[] = [
67
+ { command: normalizedCommand, cwd: entryCwd, timestamp: Date.now() },
68
+ ...existing.filter((entry) => entry.command !== normalizedCommand),
69
+ ].slice(0, 500);
70
+
71
+ const filePath = projectHistoryPath(cwd);
72
+ try {
73
+ mkdirSync(dirname(filePath), { recursive: true });
74
+ writeFileSync(filePath, JSON.stringify({ version: 1, entries: next }, null, 2) + "\n");
75
+ } catch (error) {
76
+ // History persistence should never block a successful shell command from completing.
77
+ console.debug(`[powerline-footer] Failed to persist bash project history to ${filePath}:`, error);
78
+ }
79
+ }
80
+
81
+ function parseZshHistoryLine(line: string): string | null {
82
+ const trimmed = line.trim();
83
+ if (!trimmed) return null;
84
+ if (!trimmed.startsWith(":")) return trimmed;
85
+ const parts = trimmed.split(";");
86
+ if (parts.length < 2) return null;
87
+ return parts.slice(1).join(";").trim() || null;
88
+ }
89
+
90
+ function parseBashHistory(lines: string[]): string[] {
91
+ return lines.map((line) => line.trim()).filter(Boolean);
92
+ }
93
+
94
+ function parseFishHistory(raw: string): string[] {
95
+ const matches = raw.matchAll(/^\s*-\s*cmd:\s*(.+)$/gm);
96
+ const commands: string[] = [];
97
+ for (const match of matches) {
98
+ const command = match[1]?.trim();
99
+ if (command) commands.push(command);
100
+ }
101
+ return commands;
102
+ }
103
+
104
+ export function readGlobalShellHistory(shellPath: string): string[] {
105
+ const shellName = shellPath.split("/").pop()?.toLowerCase() ?? "";
106
+ const home = getHomeDir();
107
+
108
+ try {
109
+ if (shellName.includes("zsh")) {
110
+ const filePath = process.env.HISTFILE || join(home, ".zsh_history");
111
+ if (!existsSync(filePath)) return [];
112
+ return readFileSync(filePath, "utf8")
113
+ .split("\n")
114
+ .map(parseZshHistoryLine)
115
+ .filter((entry): entry is string => Boolean(entry))
116
+ .reverse();
117
+ }
118
+
119
+ if (shellName.includes("fish")) {
120
+ const filePath = join(home, ".local", "share", "fish", "fish_history");
121
+ if (!existsSync(filePath)) return [];
122
+ return parseFishHistory(readFileSync(filePath, "utf8")).reverse();
123
+ }
124
+
125
+ const filePath = process.env.HISTFILE || join(home, ".bash_history");
126
+ if (!existsSync(filePath)) return [];
127
+ return parseBashHistory(readFileSync(filePath, "utf8").split("\n")).reverse();
128
+ } catch (error) {
129
+ // Global shell history is optional recall data. If it is unavailable, shell predictions
130
+ // should degrade to other sources instead of failing the editor.
131
+ console.debug(`[powerline-footer] Failed to read global shell history for ${shellName}:`, error);
132
+ return [];
133
+ }
134
+ }
135
+
136
+ export function matchHistoryEntries(entries: string[], prefix: string, limit: number): string[] {
137
+ const trimmedPrefix = prefix.trim();
138
+ const seen = new Set<string>();
139
+ const matches: string[] = [];
140
+
141
+ for (const rawEntry of entries) {
142
+ const entry = rawEntry?.trim();
143
+ if (!entry || seen.has(entry)) continue;
144
+ if (trimmedPrefix && !entry.startsWith(trimmedPrefix)) continue;
145
+ seen.add(entry);
146
+ matches.push(entry);
147
+ if (matches.length >= limit) break;
148
+ }
149
+
150
+ return matches;
151
+ }