@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
@@ -5,6 +5,10 @@ import type { BuiltinStatusLineSegmentId, RenderedSegment, SegmentContext, Seman
5
5
  import { normalizeCompactExtensionStatus, normalizeExtensionStatusValue } from "./powerline-config.ts";
6
6
  import { fg, rainbow, applyColor } from "./theme.ts";
7
7
  import { getIcons, SEP_DOT, getThinkingText } from "./icons.ts";
8
+ import { formatUsdCost } from "./currency-rates.ts";
9
+ import { getGitRemoteHost } from "./git-status.ts";
10
+ import type { IconSet } from "./icons.ts";
11
+ import type { GitHost } from "./git-status.ts";
8
12
 
9
13
  function color(ctx: SegmentContext, semantic: SemanticColor, text: string): string {
10
14
  return fg(ctx.theme, semantic, text, ctx.colors);
@@ -47,8 +51,10 @@ const modelSegment: StatusLineSegment = {
47
51
  const opts = ctx.options.model ?? {};
48
52
 
49
53
  let modelName = ctx.model?.name || ctx.model?.id || "no-model";
50
- // Strip "Claude " prefix for brevity
51
- if (modelName.startsWith("Claude ")) {
54
+ if (opts.display === "qualified" && ctx.model?.id) {
55
+ const provider = ctx.model.provider || ctx.model.providerId || ctx.model.providerName;
56
+ modelName = provider && !ctx.model.id.includes("/") ? `${provider}/${ctx.model.id}` : ctx.model.id;
57
+ } else if (modelName.startsWith("Claude ")) {
52
58
  modelName = modelName.slice(7);
53
59
  }
54
60
 
@@ -68,38 +74,6 @@ const modelSegment: StatusLineSegment = {
68
74
  },
69
75
  };
70
76
 
71
- const identityGroupSegment: StatusLineSegment = {
72
- id: "identity_group",
73
- render(ctx) {
74
- const model = modelSegment.render({
75
- ...ctx,
76
- options: { ...ctx.options, model: { ...(ctx.options.model ?? {}), showThinkingLevel: false } },
77
- });
78
-
79
- const parts = model.visible && model.content ? [model.content] : [];
80
- const thinkingText = getThinkingText(ctx.thinkingLevel || "off");
81
- if (thinkingText) {
82
- const level = ctx.thinkingLevel || "off";
83
- let thinkingContent: string;
84
- if (level === "high" || level === "xhigh") {
85
- thinkingContent = rainbow(thinkingText);
86
- } else if (level === "minimal") {
87
- thinkingContent = color(ctx, "thinkingMinimal", thinkingText);
88
- } else if (level === "low") {
89
- thinkingContent = color(ctx, "thinkingLow", thinkingText);
90
- } else if (level === "medium") {
91
- thinkingContent = color(ctx, "thinkingMedium", thinkingText);
92
- } else {
93
- thinkingContent = color(ctx, "thinking", thinkingText);
94
- }
95
- parts.push(thinkingContent);
96
- }
97
-
98
- if (parts.length === 0) return { content: "", visible: false };
99
- return { content: parts.join(" "), visible: true };
100
- },
101
- };
102
-
103
77
  const shellModeSegment: StatusLineSegment = {
104
78
  id: "shell_mode",
105
79
  render(ctx) {
@@ -157,14 +131,31 @@ const pathSegment: StatusLineSegment = {
157
131
  },
158
132
  };
159
133
 
134
+ /**
135
+ * Icon for the branch label: the origin remote's host logo when hostIcon is
136
+ * enabled and a remote is known, otherwise the plain branch icon. An
137
+ * unrecognized remote falls back to the generic git logo.
138
+ */
139
+ function resolveBranchIcon(icons: IconSet, hostIcon: boolean): string {
140
+ if (!hostIcon) return icons.branch;
141
+ const host = getGitRemoteHost();
142
+ const byHost: Record<GitHost, string> = {
143
+ github: icons.github,
144
+ gitlab: icons.gitlab,
145
+ bitbucket: icons.bitbucket,
146
+ other: icons.git,
147
+ };
148
+ return host ? byHost[host] : icons.branch;
149
+ }
150
+
160
151
  const gitSegment: StatusLineSegment = {
161
152
  id: "git",
162
153
  render(ctx) {
163
154
  const icons = getIcons();
164
155
  const opts = ctx.options.git ?? {};
165
156
  const { branch, staged, unstaged, untracked } = ctx.git;
166
- const gitStatus = (staged > 0 || unstaged > 0 || untracked > 0)
167
- ? { staged, unstaged, untracked }
157
+ const gitStatus = (staged > 0 || unstaged > 0 || untracked > 0)
158
+ ? { staged, unstaged, untracked }
168
159
  : null;
169
160
 
170
161
  if (!branch && !gitStatus) return { content: "", visible: false };
@@ -177,7 +168,8 @@ const gitSegment: StatusLineSegment = {
177
168
  let content = "";
178
169
  if (showBranch && branch) {
179
170
  // Color just the branch name (icon + branch text)
180
- content = color(ctx, branchColor, withIcon(icons.branch, branch));
171
+ const branchIcon = resolveBranchIcon(icons, opts.hostIcon === true);
172
+ content = color(ctx, branchColor, withIcon(branchIcon, branch));
181
173
  }
182
174
 
183
175
  // Add status indicators (each with their own color, not wrapped)
@@ -225,7 +217,7 @@ const thinkingSegment: StatusLineSegment = {
225
217
  const label = levelText[level] || level;
226
218
  const content = `think:${label}`;
227
219
 
228
- if (level === "high" || level === "xhigh") {
220
+ if (level === "high" || level === "xhigh" || level === "max") {
229
221
  return { content: rainbow(content), visible: true };
230
222
  }
231
223
 
@@ -253,6 +245,31 @@ const subagentsSegment: StatusLineSegment = {
253
245
  },
254
246
  };
255
247
 
248
+ const queueSegment: StatusLineSegment = {
249
+ id: "queue",
250
+ render(ctx) {
251
+ const summary = ctx.queueSummary;
252
+ const parts: string[] = [];
253
+
254
+ if (summary.compacting && summary.queueCount > 0) {
255
+ parts.push(`compact q ${summary.queueCount}`);
256
+ } else if (summary.queueCount > 0) {
257
+ parts.push(`q ${summary.queueCount}`);
258
+ }
259
+
260
+ if (summary.ideaCount > 0) {
261
+ parts.push(`ideas ${summary.ideaCount}`);
262
+ }
263
+
264
+ if (summary.blockedCount > 0) {
265
+ parts.push(`blocked ${summary.blockedCount}`);
266
+ }
267
+
268
+ if (parts.length === 0) return { content: "", visible: false };
269
+ return { content: color(ctx, "queue", parts.join(SEP_DOT)), visible: true };
270
+ },
271
+ };
272
+
256
273
  const tokenInSegment: StatusLineSegment = {
257
274
  id: "token_in",
258
275
  render(ctx) {
@@ -290,52 +307,32 @@ const tokenTotalSegment: StatusLineSegment = {
290
307
  },
291
308
  };
292
309
 
293
- const usageGroupSegment: StatusLineSegment = {
294
- id: "usage_group",
310
+ const costSegment: StatusLineSegment = {
311
+ id: "cost",
295
312
  render(ctx) {
296
- const icons = getIcons();
297
- const { input, output, cacheRead, cacheWrite, cost } = ctx.usageStats;
298
- const total = input + output + cacheRead + cacheWrite;
313
+ const cost = ctx.usageStats.cost + (ctx.usageStats.subagentCost ?? 0);
299
314
  const usingSubscription = ctx.usingSubscription;
300
315
 
301
- const parts: string[] = [];
302
- if (input || cacheRead) {
303
- const inText = input ? withIcon(icons.input, formatTokens(input)) : withIcon(icons.input, "0");
304
- const cacheRate = input + cacheRead > 0 ? Math.round((cacheRead / (input + cacheRead)) * 100) : 0;
305
- const cacheText = `${formatTokens(cacheRead)}/${cacheRate}%`;
306
- parts.push(color(ctx, "tokens", `${inText}(${cacheText})`));
316
+ if (!cost && !usingSubscription) {
317
+ return { content: "", visible: false };
307
318
  }
308
319
 
309
- if (output) {
310
- parts.push(color(ctx, "tokens", withIcon(icons.output, formatTokens(output))));
320
+ const reportedCost = cost > 0 ? formatUsdCost(cost, ctx.options.cost?.currency) : null;
321
+ if (!usingSubscription) {
322
+ return reportedCost
323
+ ? { content: color(ctx, "cost", reportedCost), visible: true }
324
+ : { content: "", visible: false };
311
325
  }
312
326
 
313
- const totalCostParts: string[] = [];
314
- if (total) totalCostParts.push(withIcon(icons.tokens, formatTokens(total)));
315
- if (cost || usingSubscription) {
316
- totalCostParts.push(usingSubscription ? "sub" : `$${cost.toFixed(2)}`);
327
+ const subscriptionDisplay = ctx.options.cost?.subscriptionDisplay ?? "subscription";
328
+ if (subscriptionDisplay === "reported-cost" && reportedCost) {
329
+ return { content: color(ctx, "cost", reportedCost), visible: true };
317
330
  }
318
- if (totalCostParts.length > 0) {
319
- parts.push(color(ctx, "tokens", totalCostParts.join(" ")));
320
- }
321
-
322
- if (parts.length === 0) return { content: "", visible: false };
323
- return { content: parts.join(SEP_DOT), visible: true };
324
- },
325
- };
326
-
327
- const costSegment: StatusLineSegment = {
328
- id: "cost",
329
- render(ctx) {
330
- const { cost } = ctx.usageStats;
331
- const usingSubscription = ctx.usingSubscription;
332
-
333
- if (!cost && !usingSubscription) {
334
- return { content: "", visible: false };
331
+ if (subscriptionDisplay === "both" && reportedCost) {
332
+ return { content: color(ctx, "cost", `${reportedCost} (sub)`), visible: true };
335
333
  }
336
334
 
337
- const costDisplay = usingSubscription ? "(sub)" : `$${cost.toFixed(2)}`;
338
- return { content: color(ctx, "cost", costDisplay), visible: true };
335
+ return { content: color(ctx, "cost", "(sub)"), visible: true };
339
336
  },
340
337
  };
341
338
 
@@ -345,24 +342,33 @@ const contextPctSegment: StatusLineSegment = {
345
342
  if (ctx.customCompactionEnabled) return { content: "", visible: false };
346
343
 
347
344
  const icons = getIcons();
348
- const pct = ctx.contextPercent;
349
- const window = ctx.contextWindow;
350
- const barWidth = 5;
351
- const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
352
- const bar = `[${"=".repeat(filled)}${" ".repeat(barWidth - filled)}]`;
353
- const pctDisplay = `${Math.round(pct)}%`;
354
- const text = `${bar} ${pctDisplay}/${formatTokens(window)}`;
355
-
356
- let semanticColor: "context" | "contextWarn" | "contextError";
357
- if (pct >= 70) {
358
- semanticColor = "contextError";
359
- } else if (pct >= 50) {
360
- semanticColor = "contextWarn";
345
+ const { contextTokens, contextPercent, contextWindow } = ctx;
346
+
347
+ const autoIcon = ctx.autoCompactEnabled && icons.auto ? ` ${icons.auto}` : "";
348
+ const percentOnly = ctx.options.context?.format === "percent";
349
+ const hasKnownUsage = contextTokens !== null && contextPercent !== null;
350
+ const approximate = ctx.contextApproximate ? "~" : "";
351
+ // "full" (default): tokens/window + one-decimal percentage + auto-compact icon.
352
+ // "percent": bare rounded percentage, threshold-colored, no icons.
353
+ const text = percentOnly
354
+ ? (hasKnownUsage ? `${approximate}${Math.round(contextPercent)}%` : "?")
355
+ : hasKnownUsage
356
+ ? `${approximate}${formatTokens(contextTokens)}/${formatTokens(contextWindow)} (${contextPercent.toFixed(1)}%)${autoIcon}`
357
+ : `?/${formatTokens(contextWindow)}${autoIcon}`;
358
+
359
+ // Icon outside color, text inside - use semantic colors for thresholds
360
+ let content: string;
361
+ const colored = (semantic: "context" | "contextWarn" | "contextError") =>
362
+ percentOnly ? color(ctx, semantic, text) : withIcon(icons.context, color(ctx, semantic, text));
363
+ if (hasKnownUsage && contextPercent > 90) {
364
+ content = colored("contextError");
365
+ } else if (hasKnownUsage && contextPercent > 70) {
366
+ content = colored("contextWarn");
361
367
  } else {
362
- semanticColor = "context";
368
+ content = colored("context");
363
369
  }
364
370
 
365
- return { content: withIcon(icons.context, color(ctx, semanticColor, text)), visible: true };
371
+ return { content, visible: true };
366
372
  },
367
373
  };
368
374
 
@@ -429,17 +435,6 @@ const sessionSegment: StatusLineSegment = {
429
435
  },
430
436
  };
431
437
 
432
- const sessionNameSegment: StatusLineSegment = {
433
- id: "session_name",
434
- render(ctx) {
435
- const icons = getIcons();
436
- const display = ctx.sessionName?.trim();
437
- if (!display) return { content: "", visible: false };
438
-
439
- return { content: color(ctx, "model", withIcon(icons.session, display)), visible: true };
440
- },
441
- };
442
-
443
438
  const hostnameSegment: StatusLineSegment = {
444
439
  id: "hostname",
445
440
  render() {
@@ -453,11 +448,21 @@ const cacheReadSegment: StatusLineSegment = {
453
448
  id: "cache_read",
454
449
  render(ctx) {
455
450
  const icons = getIcons();
456
- const { cacheRead } = ctx.usageStats;
451
+ const { cacheRead, input } = ctx.usageStats;
457
452
  if (!cacheRead) return { content: "", visible: false };
458
453
 
459
- const parts = [icons.cache, icons.input, formatTokens(cacheRead)].filter(Boolean);
460
- const content = parts.join(" ");
454
+ const format = ctx.options.cache_read?.format ?? "tokens";
455
+ const hitRate = input + cacheRead > 0
456
+ ? ((cacheRead / (input + cacheRead)) * 100).toFixed(0)
457
+ : "0";
458
+
459
+ let content: string;
460
+ if (format === "percent") {
461
+ content = [icons.cache, `${hitRate}%`].filter(Boolean).join(" ");
462
+ } else {
463
+ const tokens = [icons.cache, icons.input, formatTokens(cacheRead)].filter(Boolean).join(" ");
464
+ content = format === "both" ? `${tokens} (${hitRate}%)` : tokens;
465
+ }
461
466
  return { content: color(ctx, "tokens", content), visible: true };
462
467
  },
463
468
  };
@@ -508,23 +513,21 @@ const extensionStatusesSegment: StatusLineSegment = {
508
513
 
509
514
  export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
510
515
  model: modelSegment,
511
- identity_group: identityGroupSegment,
512
516
  shell_mode: shellModeSegment,
513
517
  path: pathSegment,
514
518
  git: gitSegment,
515
519
  thinking: thinkingSegment,
516
520
  subagents: subagentsSegment,
521
+ queue: queueSegment,
517
522
  token_in: tokenInSegment,
518
523
  token_out: tokenOutSegment,
519
524
  token_total: tokenTotalSegment,
520
- usage_group: usageGroupSegment,
521
525
  cost: costSegment,
522
526
  context_pct: contextPctSegment,
523
527
  context_total: contextTotalSegment,
524
528
  time_spent: timeSpentSegment,
525
529
  time: timeSegment,
526
530
  session: sessionSegment,
527
- session_name: sessionNameSegment,
528
531
  hostname: hostnameSegment,
529
532
  cache_read: cacheReadSegment,
530
533
  cache_write: cacheWriteSegment,
@@ -553,8 +556,12 @@ function renderCustomSegment(id: `custom:${string}`, ctx: SegmentContext): Rende
553
556
  return { content, visible: true };
554
557
  }
555
558
 
559
+ function isCustomSegmentId(id: StatusLineSegmentId): id is `custom:${string}` {
560
+ return id.startsWith("custom:");
561
+ }
562
+
556
563
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
557
- if (id.startsWith("custom:")) {
564
+ if (isCustomSegmentId(id)) {
558
565
  return renderCustomSegment(id, ctx);
559
566
  }
560
567
 
@@ -22,7 +22,7 @@ function readUsage(value: unknown): UsageStats | null {
22
22
  const { input, output, cacheRead, cacheWrite, cost } = value;
23
23
  if (![input, output, cacheRead, cacheWrite, cost].every(finiteNumber)) return null;
24
24
 
25
- return { input, output, cacheRead, cacheWrite, cost };
25
+ return { input, output, cacheRead, cacheWrite, cost } as UsageStats;
26
26
  }
27
27
 
28
28
  export function readSubagentToolResultUsage(message: unknown): UsageStats | null {
@@ -1,4 +1,4 @@
1
- import { matchesKey } from "@earendil-works/pi-tui";
1
+ import { isKeyRelease, matchesKey, type KeyId } from "@earendil-works/pi-tui";
2
2
 
3
3
  const SUPER_SHORTCUT_PATTERNS = new Map<string, RegExp>([
4
4
  ["super+up", /^\x1b\[(?:1;9(?::[12])?[AH]|574(?:19|23);9(?::[12])?u|7;9(?::[12])?~|27;9;65~)$/],
@@ -37,11 +37,25 @@ export function shortcutConflictKey(shortcut: string): string {
37
37
  }
38
38
  }
39
39
 
40
- export function matchesConfiguredShortcut(data: string, shortcut: string): boolean {
40
+ export function matchesConfiguredShortcut(data: string, shortcut: string | null | undefined): boolean {
41
+ if (!shortcut) return false;
42
+
41
43
  const normalizedShortcut = shortcut.toLowerCase();
42
44
  if (shortcutUsesSuper(normalizedShortcut)) {
43
45
  return SUPER_SHORTCUT_PATTERNS.get(normalizedShortcut)?.test(data) ?? false;
44
46
  }
45
47
 
46
- return matchesKey(data, shortcut);
48
+ return matchesKey(data, shortcut as KeyId);
49
+ }
50
+
51
+ export function matchesStashShortcutInput(data: string, options: { includePrintableSharpS?: boolean } = {}): boolean {
52
+ if (isKeyRelease(data)) return false;
53
+
54
+ return (options.includePrintableSharpS === true && data === "ß")
55
+ || data === "\x1bs"
56
+ || data === "\x1bS"
57
+ || /^\x1b\[(?:83|115)(?::\d*)?(?::\d*)?;3(?::\d+)?u$/.test(data)
58
+ || data === "\x1b[27;3;115~"
59
+ || data === "\x1b[27;3;83~"
60
+ || matchesKey(data, "alt+s");
47
61
  }
@@ -13,6 +13,7 @@ import {
13
13
  OneOffBashAutocompleteProvider,
14
14
  } from "../bash-mode/completion.ts";
15
15
  import { getIcons } from "../icons.ts";
16
+ import { resolveColor } from "../theme.ts";
16
17
  import { ManagedShellSession } from "../bash-mode/shell-session.ts";
17
18
 
18
19
  function getMethod(target: object, name: string): Function {
@@ -23,6 +24,13 @@ function getMethod(target: object, name: string): Function {
23
24
  return method;
24
25
  }
25
26
 
27
+ function resolveManagedShellPath(): string | null {
28
+ for (const shellPath of ["/bin/zsh", "/bin/bash"]) {
29
+ if (existsSync(shellPath)) return shellPath;
30
+ }
31
+ return null;
32
+ }
33
+
26
34
  function ensureEditorModuleLinks(): { cleanup: () => void } {
27
35
  const nodeModulesDir = join(process.cwd(), "node_modules", "@earendil-works");
28
36
  mkdirSync(nodeModulesDir, { recursive: true });
@@ -76,6 +84,38 @@ test("project history is stored newest-first and global zsh history parses histf
76
84
  assert.deepEqual(global, ["plain-command", "git pull", "git fetch"]);
77
85
  });
78
86
 
87
+ test("global history caches an unreadable file until its fingerprint changes", () => {
88
+ const cwd = mkdtempSync(join(tmpdir(), "powerline-unreadable-history-"));
89
+ const historyPath = join(cwd, ".zsh_history");
90
+ const originalHistfile = process.env.HISTFILE;
91
+ const originalDebug = console.debug;
92
+ let debugCalls = 0;
93
+
94
+ try {
95
+ mkdirSync(historyPath);
96
+ process.env.HISTFILE = historyPath;
97
+ console.debug = () => {
98
+ debugCalls += 1;
99
+ };
100
+
101
+ assert.deepEqual(readGlobalShellHistory("/bin/zsh"), []);
102
+ assert.deepEqual(readGlobalShellHistory("/bin/zsh"), []);
103
+ assert.equal(debugCalls, 1);
104
+
105
+ rmSync(historyPath, { recursive: true });
106
+ writeFileSync(historyPath, ": 1711111111:0;git status\n");
107
+ assert.deepEqual(readGlobalShellHistory("/bin/zsh"), ["git status"]);
108
+ } finally {
109
+ console.debug = originalDebug;
110
+ if (originalHistfile === undefined) {
111
+ delete process.env.HISTFILE;
112
+ } else {
113
+ process.env.HISTFILE = originalHistfile;
114
+ }
115
+ rmSync(cwd, { recursive: true, force: true });
116
+ }
117
+ });
118
+
79
119
  test("matchHistoryEntries returns newest entries when the prefix is empty", () => {
80
120
  const matches = matchHistoryEntries([
81
121
  "git stash",
@@ -115,6 +155,28 @@ test("theme.json can override icons without touching colors", () => {
115
155
  }
116
156
  });
117
157
 
158
+ test("theme.json loads from the documented agent extension path", () => {
159
+ const originalAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
160
+ const agentDir = mkdtempSync(join(tmpdir(), "powerline-theme-"));
161
+
162
+ try {
163
+ const themeDir = join(agentDir, "extensions", "powerline-footer");
164
+ mkdirSync(themeDir, { recursive: true });
165
+ writeFileSync(join(themeDir, "theme.json"), JSON.stringify({ colors: { model: "#ff5500", path: "#ff5500" } }));
166
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
167
+
168
+ assert.equal(resolveColor("model"), "#ff5500");
169
+ assert.equal(resolveColor("path"), "#ff5500");
170
+ } finally {
171
+ if (originalAgentDir === undefined) {
172
+ delete process.env.SELESAI_CODING_AGENT_DIR;
173
+ } else {
174
+ process.env.SELESAI_CODING_AGENT_DIR = originalAgentDir;
175
+ }
176
+ rmSync(agentDir, { recursive: true, force: true });
177
+ }
178
+ });
179
+
118
180
  test("one-off bash command context strips ! and !! prefixes", () => {
119
181
  assert.deepEqual(getOneOffBashCommandContext("!git status"), {
120
182
  prefix: "!",
@@ -450,12 +512,18 @@ test("deterministic path completion handles bash argument position", async () =>
450
512
  assert.equal(suggestion?.source, "path");
451
513
  });
452
514
 
453
- test("managed shell session preserves cwd changes across commands", async () => {
515
+ test("managed shell session preserves cwd changes across commands", async (t) => {
516
+ const shellPath = resolveManagedShellPath();
517
+ if (!shellPath) {
518
+ t.skip("requires zsh or bash");
519
+ return;
520
+ }
521
+
454
522
  const cwd = mkdtempSync(join(tmpdir(), "powerline-shell-"));
455
523
  const childDir = join(cwd, "child");
456
524
  mkdirSync(childDir, { recursive: true });
457
525
  const store = new BashTranscriptStore({ transcriptMaxLines: 100, transcriptMaxBytes: 64 * 1024 });
458
- const session = new ManagedShellSession("/bin/zsh", cwd, store, () => {}, () => {});
526
+ const session = new ManagedShellSession(shellPath, cwd, store, () => {}, () => {});
459
527
 
460
528
  try {
461
529
  await session.ensureReady();
@@ -482,10 +550,16 @@ test("managed shell session preserves cwd changes across commands", async () =>
482
550
  }
483
551
  });
484
552
 
485
- test("managed shell session recovers cleanly after interrupt", async () => {
553
+ test("managed shell session recovers cleanly after interrupt", async (t) => {
554
+ const shellPath = resolveManagedShellPath();
555
+ if (!shellPath) {
556
+ t.skip("requires zsh or bash");
557
+ return;
558
+ }
559
+
486
560
  const cwd = mkdtempSync(join(tmpdir(), "powerline-shell-interrupt-"));
487
561
  const store = new BashTranscriptStore({ transcriptMaxLines: 100, transcriptMaxBytes: 64 * 1024 });
488
- const session = new ManagedShellSession("/bin/zsh", cwd, store, () => {}, () => {});
562
+ const session = new ManagedShellSession(shellPath, cwd, store, () => {}, () => {});
489
563
 
490
564
  const waitForCommand = async () => {
491
565
  const start = Date.now();
@@ -661,7 +735,12 @@ test("bash editor refreshes shell ghost state after a bracketed paste completes"
661
735
  }
662
736
  });
663
737
 
664
- test("bash editor inserts Finder file drops as path strings", async () => {
738
+ test("bash editor inserts Finder file drops as path strings", async (t) => {
739
+ if (process.platform === "win32") {
740
+ t.skip("Finder file drops are macOS/POSIX paths");
741
+ return;
742
+ }
743
+
665
744
  const links = ensureEditorModuleLinks();
666
745
 
667
746
  try {
@@ -733,28 +812,26 @@ test("one-off bash autocomplete provider stays inactive even inside bang command
733
812
  assert.equal(suggestions, null);
734
813
  });
735
814
 
736
- test("bash autocomplete providers return null synchronously in shell contexts", () => {
815
+ test("bash autocomplete providers return null in shell contexts", async () => {
737
816
  const signal = new AbortController().signal;
738
817
 
739
- const bashSuggestions = new BashAutocompleteProvider().getSuggestions(["git st"], 0, 6, { signal });
740
- const oneOffSuggestions = new OneOffBashAutocompleteProvider().getSuggestions(["!git st"], 0, 7, { signal });
818
+ const bashSuggestions = await new BashAutocompleteProvider().getSuggestions(["git st"], 0, 6, { signal });
819
+ const oneOffSuggestions = await new OneOffBashAutocompleteProvider().getSuggestions(["!git st"], 0, 7, { signal });
741
820
 
742
821
  assert.equal(bashSuggestions, null);
743
822
  assert.equal(oneOffSuggestions, null);
744
- assert.equal(bashSuggestions instanceof Promise, false);
745
- assert.equal(oneOffSuggestions instanceof Promise, false);
746
823
  });
747
824
 
748
- test("mode-aware autocomplete provider preserves synchronous default results", () => {
825
+ test("mode-aware autocomplete provider preserves default results", async () => {
749
826
  const signal = new AbortController().signal;
750
- const syncResult = {
827
+ const result = {
751
828
  items: [{ value: "status", label: "status" }],
752
829
  prefix: "st",
753
830
  };
754
831
  const provider = new ModeAwareAutocompleteProvider(
755
832
  {
756
- getSuggestions() {
757
- return syncResult;
833
+ async getSuggestions() {
834
+ return result;
758
835
  },
759
836
  applyCompletion(lines: string[], cursorLine: number, cursorCol: number) {
760
837
  return { lines, cursorLine, cursorCol };
@@ -765,10 +842,9 @@ test("mode-aware autocomplete provider preserves synchronous default results", (
765
842
  () => false,
766
843
  );
767
844
 
768
- const suggestions = provider.getSuggestions(["st"], 0, 2, { signal });
845
+ const suggestions = await provider.getSuggestions(["st"], 0, 2, { signal });
769
846
 
770
- assert.equal(suggestions, syncResult);
771
- assert.equal(suggestions instanceof Promise, false);
847
+ assert.equal(suggestions, result);
772
848
  });
773
849
 
774
850
  test("one-off bash autocomplete provider stays inactive before the bang command starts", async () => {
@@ -876,6 +952,102 @@ test("bash editor shell history state does not clobber the base prompt history i
876
952
  }
877
953
  });
878
954
 
955
+ test("bash editor recalls prompt history from single-line end without losing the live draft", async () => {
956
+ const links = ensureEditorModuleLinks();
957
+
958
+ try {
959
+ const { BashModeEditor } = await import("../bash-mode/editor.ts");
960
+ const { KeybindingsManager } = await import(new URL("../node_modules/@selesai/code/dist/core/keybindings.js", import.meta.url).href);
961
+ const keybindings = KeybindingsManager.create();
962
+ const createEditor = () => new BashModeEditor(
963
+ { requestRender() {}, terminal: { columns: 80, rows: 24 } },
964
+ {},
965
+ keybindings,
966
+ {
967
+ keybindings,
968
+ isBashModeActive: () => false,
969
+ isShellRunning: () => false,
970
+ onExitBashMode() {},
971
+ onSubmitCommand() {},
972
+ onInterrupt() {},
973
+ onNotify() {},
974
+ getHistoryEntries: () => [],
975
+ resolveGhostSuggestion: async () => null,
976
+ },
977
+ );
978
+
979
+ const editor = createEditor();
980
+ editor.addToHistory("older prompt");
981
+ editor.addToHistory("previous prompt");
982
+ editor.setText("draft");
983
+
984
+ editor.handleInput("\x1b[A");
985
+ assert.equal(editor.getText(), "previous prompt");
986
+
987
+ editor.handleInput("\x1b[A");
988
+ assert.equal(editor.getText(), "older prompt");
989
+
990
+ editor.handleInput("\x1b[B");
991
+ assert.equal(editor.getText(), "previous prompt");
992
+
993
+ editor.handleInput("\x1b[B");
994
+ assert.equal(editor.getText(), "draft");
995
+
996
+ const midLineEditor = createEditor();
997
+ midLineEditor.addToHistory("previous prompt");
998
+ midLineEditor.setText("draft");
999
+ midLineEditor.handleInput("\x1b[D");
1000
+ midLineEditor.handleInput("\x1b[A");
1001
+
1002
+ assert.equal(midLineEditor.getText(), "draft");
1003
+
1004
+ const multilineEditor = createEditor();
1005
+ multilineEditor.addToHistory("previous prompt");
1006
+ multilineEditor.setText("first line\nsecond line");
1007
+ multilineEditor.handleInput("\x1b[A");
1008
+
1009
+ assert.equal(multilineEditor.getText(), "first line\nsecond line");
1010
+ assert.equal(Reflect.get(multilineEditor, "historyIndex"), -1);
1011
+
1012
+ const firstLineEditor = createEditor();
1013
+ firstLineEditor.addToHistory("previous prompt");
1014
+ firstLineEditor.setText("first line\nsecond line");
1015
+ Reflect.set(Reflect.get(firstLineEditor, "state"), "cursorLine", 0);
1016
+ Reflect.set(Reflect.get(firstLineEditor, "state"), "cursorCol", 0);
1017
+ firstLineEditor.handleInput("\x1b[A");
1018
+
1019
+ assert.equal(firstLineEditor.getText(), "previous prompt");
1020
+
1021
+ const customKeybindings = new KeybindingsManager({
1022
+ "tui.editor.cursorUp": ["up", "alt+k"],
1023
+ });
1024
+ const customBindingEditor = new BashModeEditor(
1025
+ { requestRender() {}, terminal: { columns: 80, rows: 24 } },
1026
+ {},
1027
+ customKeybindings,
1028
+ {
1029
+ keybindings: customKeybindings,
1030
+ isBashModeActive: () => false,
1031
+ isShellRunning: () => false,
1032
+ onExitBashMode() {},
1033
+ onSubmitCommand() {},
1034
+ onInterrupt() {},
1035
+ onNotify() {},
1036
+ getHistoryEntries: () => [],
1037
+ resolveGhostSuggestion: async () => null,
1038
+ },
1039
+ );
1040
+ customBindingEditor.addToHistory("previous prompt");
1041
+ customBindingEditor.setText("draft");
1042
+ customBindingEditor.handleInput("\x1bk");
1043
+
1044
+ assert.equal(customBindingEditor.getText(), "draft");
1045
+ assert.equal(Reflect.get(customBindingEditor, "historyIndex"), -1);
1046
+ } finally {
1047
+ links.cleanup();
1048
+ }
1049
+ });
1050
+
879
1051
  test("bash editor escape exits bash mode", async () => {
880
1052
  const links = ensureEditorModuleLinks();
881
1053