@selesai/code 0.7.0 → 0.8.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 (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -2
  3. package/dist/extensions/agent-browser.test.ts +195 -0
  4. package/dist/extensions/agent-browser.ts +158 -0
  5. package/dist/extensions/inline-skills.test.ts +14 -0
  6. package/dist/extensions/inline-skills.ts +14 -7
  7. package/dist/extensions/package.json +2 -1
  8. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
  9. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +156 -0
  10. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
  11. package/dist/extensions/pi-powerline-footer/README.md +192 -44
  12. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
  13. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
  14. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
  15. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
  16. package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
  17. package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
  18. package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
  19. package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
  20. package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
  21. package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
  22. package/dist/extensions/pi-powerline-footer/guide.ts +1 -1
  23. package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
  24. package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
  25. package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
  26. package/dist/extensions/pi-powerline-footer/package.json +10 -6
  27. package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
  28. package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
  29. package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
  30. package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
  31. package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
  32. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
  33. package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
  34. package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
  35. package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
  36. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
  37. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
  38. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
  39. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
  40. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
  41. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
  42. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
  43. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
  44. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +11 -6
  45. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
  46. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
  47. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
  48. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
  49. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
  50. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
  51. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
  52. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
  53. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
  54. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
  55. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
  56. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
  57. package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
  58. package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
  59. package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
  60. package/dist/extensions/pi-powerline-footer/types.ts +74 -29
  61. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
  62. package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
  63. package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
  64. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  65. package/dist/extensions/pi-subagents/package.json +1 -1
  66. package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
  67. package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
  68. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  69. package/dist/extensions/ponytail/index.js +1 -31
  70. package/dist/extensions/ponytail/test/extension.test.js +5 -22
  71. package/dist/extensions/rtk.test.ts +27 -12
  72. package/dist/extensions/rtk.ts +41 -35
  73. package/package.json +1 -1
@@ -1,15 +1,14 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { CustomEditor } from "@selesai/code";
2
+ import { CustomEditor, type KeybindingsManager } from "@selesai/code";
3
3
  import { isKeyRelease, matchesKey, visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
4
- import type { KeybindingsManager } from "@selesai/code/dist/core/keybindings.js";
5
4
  import type { AutocompleteProvider } from "@earendil-works/pi-tui";
6
5
  import { matchesConfiguredShortcut } from "../shortcuts.ts";
7
6
  import { getOneOffBashCommandContext } from "./completion.ts";
8
7
  import type { GhostSuggestion } from "./types.ts";
9
8
 
10
9
  interface EditorBoundaryShortcuts {
11
- start: string;
12
- end: string;
10
+ start: string | null;
11
+ end: string | null;
13
12
  }
14
13
 
15
14
  interface BashModeEditorOptions {
@@ -89,6 +88,12 @@ function droppedPathTextFromInput(data: string): string | null {
89
88
  return null;
90
89
  }
91
90
 
91
+ function resetShellHistoryBrowse(state: object): void {
92
+ Reflect.set(state, "shellHistoryIndex", -1);
93
+ Reflect.set(state, "shellHistoryItems", []);
94
+ Reflect.set(state, "shellHistoryDraft", "");
95
+ }
96
+
92
97
  export class BashModeEditor extends CustomEditor {
93
98
  private readonly keybindingsRef: KeybindingsManager;
94
99
  private readonly optionsRef: BashModeEditorOptions;
@@ -96,6 +101,7 @@ export class BashModeEditor extends CustomEditor {
96
101
  private shellHistoryIndex = -1;
97
102
  private shellHistoryItems: string[] = [];
98
103
  private shellHistoryDraft = "";
104
+ private promptHistoryDraft: string | null = null;
99
105
  private ghost: GhostSuggestion | null = null;
100
106
  private ghostAbort: AbortController | null = null;
101
107
  private ghostToken = 0;
@@ -106,6 +112,11 @@ export class BashModeEditor extends CustomEditor {
106
112
  this.optionsRef = options;
107
113
  }
108
114
 
115
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
116
+ super.setAutocompleteProvider(provider);
117
+ this.wrappedProviderInstalled = false;
118
+ }
119
+
109
120
  installAutocompleteProvider(provider: AutocompleteProvider): void {
110
121
  this.setAutocompleteProvider(provider);
111
122
  this.wrappedProviderInstalled = true;
@@ -130,13 +141,12 @@ export class BashModeEditor extends CustomEditor {
130
141
  }
131
142
 
132
143
  dismissBashModeUi(): void {
133
- this.shellHistoryIndex = -1;
134
- this.shellHistoryItems = [];
135
- this.shellHistoryDraft = "";
144
+ resetShellHistoryBrowse(this);
136
145
  this.clearGhostSuggestion();
137
146
 
138
- if ("cancelAutocomplete" in this && typeof this.cancelAutocomplete === "function") {
139
- this.cancelAutocomplete();
147
+ const cancelAutocomplete = Reflect.get(this, "cancelAutocomplete");
148
+ if (typeof cancelAutocomplete === "function") {
149
+ cancelAutocomplete.call(this);
140
150
  }
141
151
  this.tui.requestRender();
142
152
  }
@@ -145,9 +155,7 @@ export class BashModeEditor extends CustomEditor {
145
155
  const droppedPathText = droppedPathTextFromInput(data);
146
156
  if (droppedPathText !== null) {
147
157
  this.insertTextAtCursor(droppedPathText);
148
- this.shellHistoryIndex = -1;
149
- this.shellHistoryItems = [];
150
- this.shellHistoryDraft = "";
158
+ resetShellHistoryBrowse(this);
151
159
  if (this.isShellCompletionContext()) {
152
160
  this.scheduleGhostUpdate();
153
161
  } else {
@@ -167,10 +175,11 @@ export class BashModeEditor extends CustomEditor {
167
175
  const oneOffBashCommand = !bashMode && this.isOneOffBashCommandContext();
168
176
 
169
177
  if (isCommandUndoShortcut(data)) {
170
- this.undo();
171
- this.shellHistoryIndex = -1;
172
- this.shellHistoryItems = [];
173
- this.shellHistoryDraft = "";
178
+ const undo = Reflect.get(this, "undo");
179
+ if (typeof undo === "function") {
180
+ undo.call(this);
181
+ }
182
+ resetShellHistoryBrowse(this);
174
183
  if (this.isShellCompletionContext()) {
175
184
  this.scheduleGhostUpdate();
176
185
  } else {
@@ -223,6 +232,38 @@ export class BashModeEditor extends CustomEditor {
223
232
  return;
224
233
  }
225
234
 
235
+ if (!bashMode && matchesKey(data, "up") && this.isPromptHistoryRecallPosition()) {
236
+ const navigateHistory = Reflect.get(this, "navigateHistory");
237
+ if (typeof navigateHistory === "function") {
238
+ if (Reflect.get(this, "historyIndex") === -1) {
239
+ this.promptHistoryDraft = this.getText();
240
+ }
241
+ navigateHistory.call(this, -1);
242
+ return;
243
+ }
244
+ }
245
+
246
+ if (!bashMode && matchesKey(data, "down") && Reflect.get(this, "historyIndex") > -1) {
247
+ const isOnLastVisualLine = Reflect.get(this, "isOnLastVisualLine");
248
+ if (typeof isOnLastVisualLine !== "function" || isOnLastVisualLine.call(this)) {
249
+ const navigateHistory = Reflect.get(this, "navigateHistory");
250
+ if (typeof navigateHistory === "function") {
251
+ navigateHistory.call(this, 1);
252
+ if (Reflect.get(this, "historyIndex") === -1 && this.promptHistoryDraft !== null) {
253
+ const draft = this.promptHistoryDraft;
254
+ this.promptHistoryDraft = null;
255
+ const setTextInternal = Reflect.get(this, "setTextInternal");
256
+ if (typeof setTextInternal === "function") {
257
+ setTextInternal.call(this, draft);
258
+ } else {
259
+ this.setText(draft);
260
+ }
261
+ }
262
+ return;
263
+ }
264
+ }
265
+ }
266
+
226
267
  if (bashMode && this.keybindingsRef.matches(data, "tui.input.submit") && !this.keybindingsRef.matches(data, "tui.input.newLine")) {
227
268
  if (this.optionsRef.isShellRunning()) {
228
269
  this.optionsRef.onNotify("Shell command already running", "warning");
@@ -232,9 +273,7 @@ export class BashModeEditor extends CustomEditor {
232
273
  const command = this.getExpandedText().trim();
233
274
  if (!command) return;
234
275
  this.clearGhostSuggestion();
235
- this.shellHistoryIndex = -1;
236
- this.shellHistoryItems = [];
237
- this.shellHistoryDraft = "";
276
+ resetShellHistoryBrowse(this);
238
277
  this.optionsRef.onEditorSubmit?.();
239
278
  this.optionsRef.onSubmitCommand(command);
240
279
  this.setText("");
@@ -246,9 +285,7 @@ export class BashModeEditor extends CustomEditor {
246
285
  }
247
286
 
248
287
  if (!this.isShellCompletionContext()) {
249
- this.shellHistoryIndex = -1;
250
- this.shellHistoryItems = [];
251
- this.shellHistoryDraft = "";
288
+ resetShellHistoryBrowse(this);
252
289
  this.clearGhostSuggestion();
253
290
  return;
254
291
  }
@@ -267,9 +304,7 @@ export class BashModeEditor extends CustomEditor {
267
304
  || this.keybindingsRef.matches(data, "tui.editor.cursorLeft")
268
305
  || this.keybindingsRef.matches(data, "tui.editor.cursorRight")
269
306
  ) {
270
- this.shellHistoryIndex = -1;
271
- this.shellHistoryItems = [];
272
- this.shellHistoryDraft = "";
307
+ resetShellHistoryBrowse(this);
273
308
  this.scheduleGhostUpdate();
274
309
  }
275
310
  }
@@ -345,6 +380,26 @@ export class BashModeEditor extends CustomEditor {
345
380
  return true;
346
381
  }
347
382
 
383
+ private isPromptHistoryRecallPosition(): boolean {
384
+ if (this.isShowingAutocomplete()) return false;
385
+
386
+ const history = Reflect.get(this, "history");
387
+ if (!Array.isArray(history) || history.length === 0) return false;
388
+
389
+ const lines = this.getLines();
390
+ const cursor = this.getCursor();
391
+ if (lines.length === 1) {
392
+ return cursor.line === 0 && cursor.col === (lines[0]?.length ?? 0);
393
+ }
394
+
395
+ const isOnFirstVisualLine = Reflect.get(this, "isOnFirstVisualLine");
396
+ if (typeof isOnFirstVisualLine === "function" && !isOnFirstVisualLine.call(this)) {
397
+ return false;
398
+ }
399
+
400
+ return cursor.line === 0;
401
+ }
402
+
348
403
  private navigateShellHistory(direction: -1 | 1): void {
349
404
  const prefix = this.shellHistoryDraft || this.getExpandedText();
350
405
  if (this.shellHistoryIndex === -1) {
@@ -1,7 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
4
- import { getAgentDir } from "@selesai/code";
3
+ import { getAgentPath, getHomeDir } from "../paths.ts";
5
4
 
6
5
  interface PersistedHistoryEntry {
7
6
  command: string;
@@ -9,12 +8,31 @@ interface PersistedHistoryEntry {
9
8
  timestamp: number;
10
9
  }
11
10
 
12
- function getHomeDir(): string {
13
- return process.env.HOME || process.env.USERPROFILE || homedir();
11
+ // mode and ctimeMs are part of the fingerprint so permission/ACL transitions
12
+ // (e.g. chmod 000) invalidate the cache even when content is unchanged.
13
+ interface FileFingerprint {
14
+ ctimeMs: number;
15
+ mtimeMs: number;
16
+ mode: number;
17
+ size: number;
14
18
  }
15
19
 
20
+ interface CachedHistory<T> extends FileFingerprint {
21
+ entries: T;
22
+ }
23
+
24
+ function matchesFingerprint(cached: FileFingerprint, stat: FileFingerprint): boolean {
25
+ return cached.ctimeMs === stat.ctimeMs
26
+ && cached.mtimeMs === stat.mtimeMs
27
+ && cached.mode === stat.mode
28
+ && cached.size === stat.size;
29
+ }
30
+
31
+ const projectHistoryCache = new Map<string, CachedHistory<PersistedHistoryEntry[]>>();
32
+ const globalHistoryCache = new Map<string, CachedHistory<string[]>>();
33
+
16
34
  function getHistoryDir(): string {
17
- return join(getAgentDir(), "powerline-footer", "bash-history");
35
+ return getAgentPath("powerline-footer", "bash-history");
18
36
  }
19
37
 
20
38
  function projectKey(cwd: string): string {
@@ -44,13 +62,30 @@ function normalizePersistedEntries(value: unknown): PersistedHistoryEntry[] {
44
62
 
45
63
  export function readProjectHistory(cwd: string): PersistedHistoryEntry[] {
46
64
  const filePath = projectHistoryPath(cwd);
47
- if (!existsSync(filePath)) return [];
65
+ if (!existsSync(filePath)) {
66
+ projectHistoryCache.delete(filePath);
67
+ return [];
68
+ }
48
69
 
49
70
  try {
71
+ const stat = statSync(filePath);
72
+ const cached = projectHistoryCache.get(filePath);
73
+ if (cached && matchesFingerprint(cached, stat)) {
74
+ return cached.entries;
75
+ }
76
+
50
77
  const parsed = JSON.parse(readFileSync(filePath, "utf8"));
51
78
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
52
- return normalizePersistedEntries((parsed as { entries?: unknown }).entries)
79
+ const entries = normalizePersistedEntries((parsed as { entries?: unknown }).entries)
53
80
  .sort((a, b) => b.timestamp - a.timestamp);
81
+ projectHistoryCache.set(filePath, {
82
+ ctimeMs: stat.ctimeMs,
83
+ mtimeMs: stat.mtimeMs,
84
+ mode: stat.mode,
85
+ size: stat.size,
86
+ entries,
87
+ });
88
+ return entries;
54
89
  } catch (error) {
55
90
  // Project history is a best-effort cache. If it is unreadable or malformed,
56
91
  // bash mode should keep working instead of failing command entry entirely.
@@ -67,12 +102,23 @@ export function appendProjectHistory(cwd: string, command: string, entryCwd: str
67
102
  const next: PersistedHistoryEntry[] = [
68
103
  { command: normalizedCommand, cwd: entryCwd, timestamp: Date.now() },
69
104
  ...existing.filter((entry) => entry.command !== normalizedCommand),
70
- ].slice(0, 500);
105
+ ]
106
+ .slice(0, 500)
107
+ .sort((a, b) => b.timestamp - a.timestamp);
71
108
 
72
109
  const filePath = projectHistoryPath(cwd);
110
+ projectHistoryCache.delete(filePath);
73
111
  try {
74
112
  mkdirSync(dirname(filePath), { recursive: true });
75
113
  writeFileSync(filePath, JSON.stringify({ version: 1, entries: next }, null, 2) + "\n");
114
+ const stat = statSync(filePath);
115
+ projectHistoryCache.set(filePath, {
116
+ ctimeMs: stat.ctimeMs,
117
+ mtimeMs: stat.mtimeMs,
118
+ mode: stat.mode,
119
+ size: stat.size,
120
+ entries: next,
121
+ });
76
122
  } catch (error) {
77
123
  // History persistence should never block a successful shell command from completing.
78
124
  console.debug(`[powerline-footer] Failed to persist bash project history to ${filePath}:`, error);
@@ -105,31 +151,37 @@ function parseFishHistory(raw: string): string[] {
105
151
  export function readGlobalShellHistory(shellPath: string): string[] {
106
152
  const shellName = shellPath.split("/").pop()?.toLowerCase() ?? "";
107
153
  const home = getHomeDir();
154
+ const filePath = shellName.includes("fish")
155
+ ? join(home, ".local", "share", "fish", "fish_history")
156
+ : process.env.HISTFILE || join(home, shellName.includes("zsh") ? ".zsh_history" : ".bash_history");
157
+ const cacheKey = `${shellName}\0${filePath}`;
108
158
 
109
- try {
110
- if (shellName.includes("zsh")) {
111
- const filePath = process.env.HISTFILE || join(home, ".zsh_history");
112
- if (!existsSync(filePath)) return [];
113
- return readFileSync(filePath, "utf8")
114
- .split("\n")
115
- .map(parseZshHistoryLine)
116
- .filter((entry): entry is string => Boolean(entry))
117
- .reverse();
118
- }
159
+ if (!existsSync(filePath)) {
160
+ globalHistoryCache.delete(cacheKey);
161
+ return [];
162
+ }
119
163
 
120
- if (shellName.includes("fish")) {
121
- const filePath = join(home, ".local", "share", "fish", "fish_history");
122
- if (!existsSync(filePath)) return [];
123
- return parseFishHistory(readFileSync(filePath, "utf8")).reverse();
164
+ let stat: FileFingerprint | undefined;
165
+ try {
166
+ stat = statSync(filePath);
167
+ const cached = globalHistoryCache.get(cacheKey);
168
+ if (cached && matchesFingerprint(cached, stat)) {
169
+ return cached.entries;
124
170
  }
125
171
 
126
- const filePath = process.env.HISTFILE || join(home, ".bash_history");
127
- if (!existsSync(filePath)) return [];
128
- return parseBashHistory(readFileSync(filePath, "utf8").split("\n")).reverse();
172
+ const raw = readFileSync(filePath, "utf8");
173
+ const entries = shellName.includes("zsh")
174
+ ? raw.split("\n").map(parseZshHistoryLine).filter((entry): entry is string => Boolean(entry)).reverse()
175
+ : shellName.includes("fish")
176
+ ? parseFishHistory(raw).reverse()
177
+ : parseBashHistory(raw.split("\n")).reverse();
178
+ globalHistoryCache.set(cacheKey, { ...stat, entries });
179
+ return entries;
129
180
  } catch (error) {
130
- // Global shell history is optional recall data. If it is unavailable, shell predictions
131
- // should degrade to other sources instead of failing the editor.
132
- console.debug(`[powerline-footer] Failed to read global shell history for ${shellName}:`, error);
181
+ if (stat) {
182
+ globalHistoryCache.set(cacheKey, { ...stat, entries: [] });
183
+ console.debug(`[powerline-footer] Failed to read global shell history for ${shellName}:`, error);
184
+ }
133
185
  return [];
134
186
  }
135
187
  }
@@ -1,5 +1,5 @@
1
1
  export interface BashModeSettings {
2
- toggleShortcut: string;
2
+ toggleShortcut: string | null;
3
3
  transcriptMaxLines: number;
4
4
  transcriptMaxBytes: number;
5
5
  }
@@ -0,0 +1,190 @@
1
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@selesai/code";
5
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
6
+
7
+ function stripMatchingQuotes(value: string): string {
8
+ const trimmed = value.trim();
9
+ if (trimmed.length >= 2) {
10
+ const first = trimmed[0];
11
+ const last = trimmed[trimmed.length - 1];
12
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
13
+ return trimmed.slice(1, -1);
14
+ }
15
+ }
16
+ return trimmed;
17
+ }
18
+
19
+ function unescapePathArg(value: string): string {
20
+ return stripMatchingQuotes(value).replace(/\\(.)/g, "$1");
21
+ }
22
+
23
+ function expandHomePath(value: string, homeDir = homedir()): string {
24
+ if (value === "~") return homeDir;
25
+ if (value.startsWith("~/")) return join(homeDir, value.slice(2));
26
+ return value;
27
+ }
28
+
29
+ function escapeCompletionPath(value: string): string {
30
+ return value.replace(/([\\\s"'`$&|;<>()[\]{}?!*])/g, "\\$1");
31
+ }
32
+
33
+ function displayPath(value: string): string {
34
+ return value.replace(/\\/g, "/");
35
+ }
36
+
37
+ export function resolveCdTarget(args: string, cwd: string, homeDir = homedir()): { path: string } | { error: string } | null {
38
+ const input = unescapePathArg(args);
39
+ if (!input) return null;
40
+
41
+ const expanded = expandHomePath(input, homeDir);
42
+ const target = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
43
+
44
+ if (!existsSync(target)) {
45
+ return { error: `Directory does not exist: ${target}` };
46
+ }
47
+
48
+ try {
49
+ if (!statSync(target).isDirectory()) {
50
+ return { error: `Not a directory: ${target}` };
51
+ }
52
+ } catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ return { error: `Cannot access directory: ${message}` };
55
+ }
56
+
57
+ return { path: target };
58
+ }
59
+
60
+ function pathBase(token: string, cwd: string, homeDir: string): { dir: string; prefix: string; displayPrefix: string } {
61
+ const unescaped = unescapePathArg(token);
62
+ const expanded = expandHomePath(unescaped, homeDir);
63
+ const hasSlash = expanded.includes("/");
64
+
65
+ if (!hasSlash) {
66
+ return { dir: cwd, prefix: expanded, displayPrefix: "" };
67
+ }
68
+
69
+ const baseDir = expanded.endsWith("/") ? expanded.slice(0, -1) : dirname(expanded);
70
+ const dir = isAbsolute(baseDir) ? baseDir : resolve(cwd, baseDir);
71
+ const prefix = expanded.endsWith("/") ? "" : basename(expanded);
72
+ const displayPrefix = unescaped.endsWith("/") ? unescaped : unescaped.slice(0, Math.max(0, unescaped.length - prefix.length));
73
+ return { dir, prefix, displayPrefix };
74
+ }
75
+
76
+ function isDirectoryEntry(dir: string, name: string): boolean {
77
+ try {
78
+ return statSync(join(dir, name)).isDirectory();
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ export function getCdArgumentCompletions(argumentPrefix: string, cwd: string, homeDir = homedir()): AutocompleteItem[] {
85
+ const prefix = argumentPrefix.trimStart();
86
+ const suggestions: AutocompleteItem[] = [];
87
+
88
+ for (const quickPick of ["../", "~/"]) {
89
+ if (!prefix || quickPick.startsWith(prefix)) {
90
+ suggestions.push({ value: quickPick, label: quickPick, description: quickPick === "../" ? "Parent directory" : "Home directory" });
91
+ }
92
+ }
93
+
94
+ const { dir, prefix: entryPrefix, displayPrefix } = pathBase(prefix, cwd, homeDir);
95
+
96
+ try {
97
+ const entries = readdirSync(dir, { withFileTypes: true })
98
+ .filter((entry) => entry.name.startsWith(entryPrefix) && (entry.isDirectory() || entry.isSymbolicLink() && isDirectoryEntry(dir, entry.name)))
99
+ .slice(0, 50)
100
+ .map((entry) => {
101
+ const path = displayPath(`${displayPrefix}${entry.name}/`);
102
+ return {
103
+ value: escapeCompletionPath(path),
104
+ label: path,
105
+ } satisfies AutocompleteItem;
106
+ });
107
+
108
+ suggestions.push(...entries);
109
+ } catch {
110
+ return suggestions;
111
+ }
112
+
113
+ const seen = new Set<string>();
114
+ return suggestions.filter((item) => {
115
+ if (seen.has(item.value)) return false;
116
+ seen.add(item.value);
117
+ return true;
118
+ });
119
+ }
120
+
121
+ function removeSessionFile(filePath: string | undefined): void {
122
+ if (!filePath) return;
123
+ try {
124
+ unlinkSync(filePath);
125
+ } catch {
126
+ // Best-effort cleanup only; the command still reports the cancellation/error.
127
+ }
128
+ }
129
+
130
+ export async function runCdCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
131
+ const resolved = resolveCdTarget(args, ctx.cwd);
132
+ if (resolved === null) {
133
+ ctx.ui.notify(ctx.cwd, "info");
134
+ return;
135
+ }
136
+ if ("error" in resolved) {
137
+ ctx.ui.notify(resolved.error, "error");
138
+ return;
139
+ }
140
+ if (resolved.path === ctx.cwd) {
141
+ ctx.ui.notify(`Already in ${ctx.cwd}`, "info");
142
+ return;
143
+ }
144
+
145
+ const sessionFile = ctx.sessionManager.getSessionFile();
146
+ if (!sessionFile) {
147
+ ctx.ui.notify("/cd requires a persisted Pi session", "error");
148
+ return;
149
+ }
150
+
151
+ await ctx.waitForIdle();
152
+ const nextSession = SessionManager.forkFrom(sessionFile, resolved.path);
153
+ const nextSessionFile = nextSession.getSessionFile();
154
+
155
+ try {
156
+ const result = await ctx.switchSession(nextSessionFile!, {
157
+ withSession: async (nextCtx) => {
158
+ try {
159
+ process.chdir(nextCtx.cwd);
160
+ nextCtx.ui.notify(`Changed directory to ${nextCtx.cwd}`, "info");
161
+ nextCtx.ui.setTitle(`pi - ${basename(nextCtx.cwd) || nextCtx.cwd}`);
162
+ } catch (error) {
163
+ console.debug("[powerline-footer] Failed to update process cwd after /cd:", error);
164
+ }
165
+ },
166
+ });
167
+ if (result.cancelled) {
168
+ removeSessionFile(nextSessionFile);
169
+ ctx.ui.notify("Directory change cancelled", "warning");
170
+ return;
171
+ }
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ try {
175
+ ctx.ui.notify(`Failed to change directory: ${message}`, "error");
176
+ } catch {
177
+ console.debug("[powerline-footer] Failed to report /cd error:", error);
178
+ }
179
+ }
180
+ }
181
+
182
+ export function registerCdCommand(pi: ExtensionAPI, getCwd: () => string): void {
183
+ pi.registerCommand("cd", {
184
+ description: "Switch the Pi session working directory",
185
+ getArgumentCompletions(argumentPrefix) {
186
+ return getCdArgumentCompletions(argumentPrefix, getCwd());
187
+ },
188
+ handler: runCdCommand,
189
+ });
190
+ }
@@ -32,10 +32,11 @@ const THEME = {
32
32
  path: "#00afaf", // Teal/cyan
33
33
  gitClean: "#5faf5f", // Green
34
34
  accent: "#febc38", // Orange
35
+ queue: "#febc38", // Orange
35
36
  };
36
37
 
37
38
  // Color name to ANSI code mapping
38
- type ColorName = "sep" | "model" | "path" | "gitClean" | "accent";
39
+ type ColorName = keyof typeof THEME;
39
40
 
40
41
  function getAnsiCode(color: ColorName): string {
41
42
  const value = THEME[color as keyof typeof THEME];