@oh-my-pi/pi-coding-agent 16.1.13 → 16.1.15

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 (153) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +5615 -2833
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +64 -14
  5. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
  6. package/dist/types/eval/backend-helpers.d.ts +23 -0
  7. package/dist/types/eval/executor-base.d.ts +118 -0
  8. package/dist/types/eval/index.d.ts +2 -0
  9. package/dist/types/eval/jl/executor.d.ts +44 -0
  10. package/dist/types/eval/jl/index.d.ts +11 -0
  11. package/dist/types/eval/jl/kernel.d.ts +28 -0
  12. package/dist/types/eval/jl/prelude.d.ts +1 -0
  13. package/dist/types/eval/jl/runtime.d.ts +22 -0
  14. package/dist/types/eval/kernel-base.d.ts +105 -0
  15. package/dist/types/eval/py/kernel.d.ts +3 -61
  16. package/dist/types/eval/rb/executor.d.ts +77 -0
  17. package/dist/types/eval/rb/index.d.ts +11 -0
  18. package/dist/types/eval/rb/kernel.d.ts +31 -0
  19. package/dist/types/eval/rb/prelude.d.ts +1 -0
  20. package/dist/types/eval/rb/runtime.d.ts +23 -0
  21. package/dist/types/eval/runtime-env.d.ts +24 -0
  22. package/dist/types/eval/types.d.ts +3 -3
  23. package/dist/types/export/share.d.ts +8 -1
  24. package/dist/types/extensibility/extensions/runner.d.ts +2 -15
  25. package/dist/types/extensibility/hooks/loader.d.ts +1 -25
  26. package/dist/types/extensibility/session-handler-types.d.ts +23 -0
  27. package/dist/types/jsonrpc/message-framing.d.ts +35 -0
  28. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  29. package/dist/types/mnemopi/embed-client.d.ts +7 -24
  30. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
  31. package/dist/types/modes/components/custom-editor.d.ts +11 -0
  32. package/dist/types/modes/components/selector-helpers.d.ts +53 -0
  33. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  34. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  35. package/dist/types/modes/interactive-mode.d.ts +0 -2
  36. package/dist/types/modes/theme/theme.d.ts +8 -1
  37. package/dist/types/modes/types.d.ts +0 -2
  38. package/dist/types/modes/utils/copy-targets.d.ts +1 -1
  39. package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
  40. package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
  41. package/dist/types/sdk.d.ts +1 -1
  42. package/dist/types/secrets/obfuscator.d.ts +3 -3
  43. package/dist/types/session/agent-session.d.ts +2 -2
  44. package/dist/types/stt/asr-client.d.ts +3 -29
  45. package/dist/types/subprocess/worker-client.d.ts +149 -0
  46. package/dist/types/subprocess/worker-runtime.d.ts +107 -0
  47. package/dist/types/tiny/title-client.d.ts +14 -34
  48. package/dist/types/tools/eval-backends.d.ts +6 -3
  49. package/dist/types/tools/eval-render.d.ts +6 -5
  50. package/dist/types/tools/eval.d.ts +13 -15
  51. package/dist/types/tools/index.d.ts +3 -3
  52. package/dist/types/tts/tts-client.d.ts +3 -28
  53. package/dist/types/tui/code-cell.d.ts +7 -0
  54. package/dist/types/utils/file-display-mode.d.ts +1 -1
  55. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  56. package/dist/types/web/parallel.d.ts +6 -0
  57. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  58. package/package.json +12 -12
  59. package/src/advisor/__tests__/advisor.test.ts +114 -0
  60. package/src/advisor/runtime.ts +129 -1
  61. package/src/config/model-registry.ts +12 -4
  62. package/src/config/settings-schema.ts +74 -18
  63. package/src/config/settings.ts +5 -0
  64. package/src/dap/client.ts +13 -107
  65. package/src/eval/__tests__/julia-prelude.test.ts +77 -0
  66. package/src/eval/backend-helpers.ts +48 -0
  67. package/src/eval/executor-base.ts +425 -0
  68. package/src/eval/index.ts +2 -0
  69. package/src/eval/jl/executor.ts +540 -0
  70. package/src/eval/jl/index.ts +54 -0
  71. package/src/eval/jl/kernel.ts +235 -0
  72. package/src/eval/jl/prelude.jl +930 -0
  73. package/src/eval/jl/prelude.ts +3 -0
  74. package/src/eval/jl/runner.jl +634 -0
  75. package/src/eval/jl/runtime.ts +118 -0
  76. package/src/eval/js/index.ts +3 -14
  77. package/src/eval/kernel-base.ts +569 -0
  78. package/src/eval/py/executor.ts +43 -252
  79. package/src/eval/py/index.ts +9 -20
  80. package/src/eval/py/kernel.ts +29 -544
  81. package/src/eval/rb/executor.ts +504 -0
  82. package/src/eval/rb/index.ts +54 -0
  83. package/src/eval/rb/kernel.ts +230 -0
  84. package/src/eval/rb/prelude.rb +721 -0
  85. package/src/eval/rb/prelude.ts +3 -0
  86. package/src/eval/rb/runner.rb +474 -0
  87. package/src/eval/rb/runtime.ts +132 -0
  88. package/src/eval/runtime-env.ts +104 -0
  89. package/src/eval/types.ts +3 -3
  90. package/src/exec/bash-executor.ts +44 -0
  91. package/src/export/share.ts +51 -28
  92. package/src/extensibility/extensions/runner.ts +4 -11
  93. package/src/extensibility/hooks/loader.ts +3 -21
  94. package/src/extensibility/session-handler-types.ts +21 -0
  95. package/src/internal-urls/docs-index.generated.txt +1 -1
  96. package/src/jsonrpc/message-framing.ts +142 -0
  97. package/src/lsp/client.ts +13 -109
  98. package/src/mcp/transports/stdio.ts +20 -4
  99. package/src/mnemopi/embed-client.ts +43 -198
  100. package/src/modes/components/agent-dashboard.ts +17 -40
  101. package/src/modes/components/chat-transcript-builder.ts +18 -102
  102. package/src/modes/components/custom-editor.test.ts +22 -0
  103. package/src/modes/components/custom-editor.ts +29 -1
  104. package/src/modes/components/extensions/extension-dashboard.ts +4 -13
  105. package/src/modes/components/extensions/extension-list.ts +16 -44
  106. package/src/modes/components/footer.ts +4 -3
  107. package/src/modes/components/history-search.ts +4 -16
  108. package/src/modes/components/selector-helpers.ts +129 -0
  109. package/src/modes/components/settings-selector.ts +7 -5
  110. package/src/modes/components/status-line/component.ts +5 -1
  111. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  112. package/src/modes/components/status-line/segments.ts +1 -1
  113. package/src/modes/components/status-line/types.ts +1 -0
  114. package/src/modes/components/tree-selector.ts +13 -18
  115. package/src/modes/controllers/command-controller.ts +1 -0
  116. package/src/modes/controllers/event-controller.ts +3 -9
  117. package/src/modes/controllers/input-controller.ts +32 -54
  118. package/src/modes/interactive-mode.ts +5 -7
  119. package/src/modes/theme/theme.ts +35 -0
  120. package/src/modes/types.ts +0 -2
  121. package/src/modes/utils/copy-targets.ts +3 -2
  122. package/src/modes/utils/interactive-context-helpers.ts +27 -0
  123. package/src/modes/utils/transcript-render-helpers.ts +157 -0
  124. package/src/modes/utils/ui-helpers.ts +21 -126
  125. package/src/prompts/system/system-prompt.md +5 -5
  126. package/src/prompts/tools/bash.md +2 -3
  127. package/src/prompts/tools/eval.md +6 -4
  128. package/src/prompts/tools/find.md +0 -4
  129. package/src/prompts/tools/read.md +1 -2
  130. package/src/prompts/tools/replace.md +1 -1
  131. package/src/sdk.ts +13 -7
  132. package/src/secrets/obfuscator.ts +3 -9
  133. package/src/session/agent-session.ts +42 -9
  134. package/src/slash-commands/builtin-registry.ts +2 -1
  135. package/src/stt/asr-client.ts +35 -215
  136. package/src/stt/asr-worker.ts +29 -181
  137. package/src/subprocess/worker-client.ts +297 -0
  138. package/src/subprocess/worker-runtime.ts +277 -0
  139. package/src/task/executor.ts +4 -4
  140. package/src/tiny/title-client.ts +53 -219
  141. package/src/tiny/worker.ts +29 -180
  142. package/src/tools/eval-backends.ts +10 -3
  143. package/src/tools/eval-render.ts +17 -8
  144. package/src/tools/eval.ts +187 -22
  145. package/src/tools/index.ts +51 -28
  146. package/src/tts/tts-client.ts +38 -206
  147. package/src/tts/tts-worker.ts +23 -97
  148. package/src/tui/code-cell.ts +12 -1
  149. package/src/utils/file-display-mode.ts +2 -3
  150. package/src/utils/shell-snapshot.ts +63 -1
  151. package/src/web/parallel.ts +43 -42
  152. package/src/web/search/providers/parallel.ts +10 -99
  153. package/src/web/search/providers/perplexity.ts +18 -6
@@ -21,7 +21,6 @@ import {
21
21
  type Component,
22
22
  Container,
23
23
  Editor,
24
- extractPrintableText,
25
24
  fuzzyMatch,
26
25
  Input,
27
26
  matchesKey,
@@ -59,6 +58,7 @@ import {
59
58
  matchesSelectUp,
60
59
  } from "../utils/keybinding-matchers";
61
60
  import { DynamicBorder } from "./dynamic-border";
61
+ import { clampSelection, handleTabSwitchKey, padLinesToHeight, searchableChar } from "./selector-helpers";
62
62
 
63
63
  type SourceTabId = "all" | AgentSource;
64
64
  type AgentScope = "project" | "user";
@@ -521,31 +521,19 @@ export class AgentDashboard extends Container {
521
521
  const lines = super.render(width);
522
522
  // Pad to the full viewport so every state (list, edit, create) covers the
523
523
  // screen as a true full-screen view instead of letting the transcript peek
524
- // through below it. Copy before padding — the container's render result is
525
- // component-owned and must not be mutated.
526
- const rows = this.#terminalRows();
527
- if (lines.length >= rows) return lines;
528
- const padded = lines.slice();
529
- while (padded.length < rows) padded.push("");
530
- return padded;
524
+ // through below it.
525
+ return padLinesToHeight(lines, this.#terminalRows());
531
526
  }
532
527
 
533
528
  #clampSelection(): void {
534
- if (this.#filteredAgents.length === 0) {
535
- this.#selectedIndex = 0;
536
- this.#scrollOffset = 0;
537
- return;
538
- }
539
-
540
- this.#selectedIndex = Math.min(this.#selectedIndex, this.#filteredAgents.length - 1);
541
- this.#selectedIndex = Math.max(0, this.#selectedIndex);
542
-
543
- const maxVisible = this.#getMaxVisibleItems();
544
- if (this.#selectedIndex < this.#scrollOffset) {
545
- this.#scrollOffset = this.#selectedIndex;
546
- } else if (this.#selectedIndex >= this.#scrollOffset + maxVisible) {
547
- this.#scrollOffset = this.#selectedIndex - maxVisible + 1;
548
- }
529
+ const next = clampSelection(
530
+ this.#selectedIndex,
531
+ this.#scrollOffset,
532
+ this.#filteredAgents.length,
533
+ this.#getMaxVisibleItems(),
534
+ );
535
+ this.#selectedIndex = next.selectedIndex;
536
+ this.#scrollOffset = next.scrollOffset;
549
537
  }
550
538
 
551
539
  #persistDisabledAgents(): void {
@@ -1150,12 +1138,7 @@ export class AgentDashboard extends Container {
1150
1138
  return;
1151
1139
  }
1152
1140
 
1153
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
1154
- this.#switchTab(1);
1155
- return;
1156
- }
1157
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
1158
- this.#switchTab(-1);
1141
+ if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) {
1159
1142
  return;
1160
1143
  }
1161
1144
 
@@ -1190,17 +1173,11 @@ export class AgentDashboard extends Container {
1190
1173
  return;
1191
1174
  }
1192
1175
 
1193
- const printableText = extractPrintableText(data);
1194
- if (printableText && printableText.length === 1) {
1195
- const printableCharCode = printableText.charCodeAt(0);
1196
- if (printableCharCode > 32 && printableCharCode < 127) {
1197
- if (printableText === "j" || printableText === "k") {
1198
- return;
1199
- }
1200
- this.#searchQuery += printableText;
1201
- this.#applyFilters();
1202
- this.#buildLayout();
1203
- }
1176
+ const char = searchableChar(data);
1177
+ if (char !== null) {
1178
+ this.#searchQuery += char;
1179
+ this.#applyFilters();
1180
+ this.#buildLayout();
1204
1181
  }
1205
1182
  }
1206
1183
  }
@@ -13,8 +13,7 @@
13
13
  */
14
14
  import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
15
15
  import type { Usage } from "@oh-my-pi/pi-ai";
16
- import { Text, type TUI } from "@oh-my-pi/pi-tui";
17
- import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
16
+ import type { TUI } from "@oh-my-pi/pi-tui";
18
17
  import type { AdvisorMessageDetails } from "../../advisor";
19
18
  import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
20
19
  import { settings } from "../../config/settings";
@@ -22,16 +21,20 @@ import type { MessageRenderer } from "../../extensibility/extensions/types";
22
21
  import {
23
22
  BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
24
23
  type CustomMessage,
25
- isSilentAbort,
26
24
  LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
27
- resolveAbortLabel,
28
25
  SKILL_PROMPT_MESSAGE_TYPE,
29
26
  type SkillPromptDetails,
30
27
  } from "../../session/messages";
31
28
  import type { SessionMessageEntry } from "../../session/session-entries";
32
- import { createIrcMessageCard } from "../../tools/irc";
33
- import { canonicalizeMessage } from "../../utils/thinking-display";
34
29
  import { theme } from "../theme/theme";
30
+ import {
31
+ assistantHasVisibleContent,
32
+ buildAsyncResultBlock,
33
+ buildFileMentionBlock,
34
+ buildIrcMessageCard,
35
+ normalizeToolArgs,
36
+ resolveAssistantErrorMessage,
37
+ } from "../utils/transcript-render-helpers";
35
38
  import { createAdvisorMessageCard } from "./advisor-message";
36
39
  import { AssistantMessageComponent } from "./assistant-message";
37
40
  import { createBackgroundTanDispatchBlock } from "./background-tan-message";
@@ -49,7 +52,7 @@ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./lat
49
52
  import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
50
53
  import { SkillMessageComponent } from "./skill-message";
51
54
  import { ToolExecutionComponent } from "./tool-execution";
52
- import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
55
+ import { TranscriptContainer } from "./transcript-container";
53
56
  import { createUsageRowBlock } from "./usage-row";
54
57
  import { UserMessageComponent } from "./user-message";
55
58
 
@@ -218,27 +221,9 @@ export class ChatTranscriptBuilder {
218
221
  break;
219
222
  }
220
223
  case "fileMention": {
221
- const block = new TranscriptBlock();
222
- for (const file of message.files) {
223
- let suffix: string;
224
- if (file.skippedReason === "tooLarge") {
225
- const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
226
- suffix = `(skipped: ${size})`;
227
- } else {
228
- suffix = file.image
229
- ? "(image)"
230
- : file.lineCount === undefined
231
- ? "(unknown lines)"
232
- : `(${file.lineCount} lines)`;
233
- }
234
- const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
235
- "accent",
236
- file.path,
237
- )} ${theme.fg("dim", suffix)}`;
238
- // Indent one column to match the transcript's other rows (the viewer renders
239
- // body rows without an outer gutter; rows own their left pad).
240
- block.addChild(new Text(text, 1, 0));
241
- }
224
+ // Indent one column to match the transcript's other rows (the viewer renders
225
+ // body rows without an outer gutter; rows own their left pad).
226
+ const block = buildFileMentionBlock(message.files, 1);
242
227
  if (block.children.length > 0) this.container.addChild(block);
243
228
  break;
244
229
  }
@@ -266,24 +251,14 @@ export class ChatTranscriptBuilder {
266
251
  this.#lastAssistantUsage = message.usage;
267
252
  }
268
253
 
269
- const hasVisibleAssistantContent = message.content.some(
270
- content =>
271
- (content.type === "text" && canonicalizeMessage(content.text)) ||
272
- (content.type === "thinking" && canonicalizeMessage(content.thinking)),
273
- );
254
+ const hasVisibleAssistantContent = assistantHasVisibleContent(message);
274
255
  if (hasVisibleAssistantContent) {
275
256
  // New visible turn content closes the current read run (mirrors rebuild).
276
257
  this.#readGroup?.seal();
277
258
  this.#readGroup = null;
278
259
  }
279
260
 
280
- const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
281
- const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
282
- const errorMessage = hasErrorStop
283
- ? message.stopReason === "aborted"
284
- ? resolveAbortLabel(message.errorMessage)
285
- : message.errorMessage || "Error"
286
- : null;
261
+ const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(message);
287
262
 
288
263
  for (const content of message.content) {
289
264
  if (content.type !== "toolCall") continue;
@@ -303,10 +278,7 @@ export class ChatTranscriptBuilder {
303
278
  content.id,
304
279
  );
305
280
  } else {
306
- const normalizedArgs =
307
- content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
308
- ? (content.arguments as Record<string, unknown>)
309
- : {};
281
+ const normalizedArgs = normalizeToolArgs(content.arguments);
310
282
  this.#readArgs.set(content.id, normalizedArgs);
311
283
  }
312
284
  continue;
@@ -373,42 +345,7 @@ export class ChatTranscriptBuilder {
373
345
  #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
374
346
  if (!message.display) return;
375
347
  if (message.customType === "async-result") {
376
- const details = (
377
- message as CustomMessage<{
378
- jobId?: string;
379
- type?: "bash" | "task";
380
- label?: string;
381
- durationMs?: number;
382
- jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
383
- }>
384
- ).details;
385
- const jobs =
386
- details?.jobs && details.jobs.length > 0
387
- ? details.jobs
388
- : [
389
- {
390
- jobId: details?.jobId,
391
- type: details?.type,
392
- label: details?.label,
393
- durationMs: details?.durationMs,
394
- },
395
- ];
396
- const block = new TranscriptBlock();
397
- for (const job of jobs) {
398
- const jobId = job.jobId ?? "unknown";
399
- const typeLabel = job.type ? `[${job.type}]` : "[job]";
400
- const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
401
- const line = [
402
- theme.fg("success", `${theme.status.done} Background job completed`),
403
- theme.fg("dim", typeLabel),
404
- theme.fg("accent", jobId),
405
- duration ? theme.fg("dim", `(${duration})`) : undefined,
406
- ]
407
- .filter(Boolean)
408
- .join(" ");
409
- block.addChild(new Text(line, 1, 0));
410
- }
411
- this.container.addChild(block);
348
+ this.container.addChild(buildAsyncResultBlock(message));
412
349
  return;
413
350
  }
414
351
  if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
@@ -433,28 +370,7 @@ export class ChatTranscriptBuilder {
433
370
  message.customType === "irc:autoreply" ||
434
371
  message.customType === "irc:relay"
435
372
  ) {
436
- const details = (
437
- message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
438
- ).details;
439
- const kind =
440
- message.customType === "irc:incoming"
441
- ? ("incoming" as const)
442
- : message.customType === "irc:autoreply"
443
- ? ("autoreply" as const)
444
- : ("relay" as const);
445
- const card = createIrcMessageCard(
446
- {
447
- kind,
448
- from: details?.from,
449
- to: details?.to,
450
- body: kind === "incoming" ? details?.message : details?.body,
451
- replyTo: details?.replyTo,
452
- timestamp: message.timestamp,
453
- },
454
- () => this.#expanded,
455
- theme,
456
- );
457
- this.container.addChild(card);
373
+ this.container.addChild(buildIrcMessageCard(message, () => this.#expanded));
458
374
  return;
459
375
  }
460
376
  if (message.customType === "advisor") {
@@ -3,6 +3,7 @@ import { $ } from "bun";
3
3
  import { getEditorTheme, initTheme } from "../theme/theme";
4
4
  import {
5
5
  CustomEditor,
6
+ extractBracketedImagePastePaths,
6
7
  SPACE_HOLD_MECHANICAL_RUN,
7
8
  SPACE_HOLD_RELEASE_MS,
8
9
  SPACE_REPEAT_MAX_GAP_MS,
@@ -21,6 +22,12 @@ function makeEditor() {
21
22
  const REPEAT_GAP_MS = 30;
22
23
  /** A gap above the threshold — looks like a deliberate keypress. */
23
24
  const TAP_GAP_MS = SPACE_REPEAT_MAX_GAP_MS + 80;
25
+ const BRACKETED_PASTE_START = "\x1b[200~";
26
+ const BRACKETED_PASTE_END = "\x1b[201~";
27
+
28
+ function bracketedPaste(text: string): string {
29
+ return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
30
+ }
24
31
 
25
32
  /** Feed `count` spaces `gapMs` apart on the fake clock. The first space of a run has no prior
26
33
  * space, so its gap is effectively infinite and it always reads as a deliberate tap. */
@@ -66,6 +73,21 @@ describe("CustomEditor placeholder decoration", () => {
66
73
  });
67
74
  });
68
75
 
76
+ describe("CustomEditor bracketed image-path paste", () => {
77
+ it("leaves a pasted bare .png filename on the normal text path", () => {
78
+ expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
79
+ });
80
+
81
+ it("extracts explicit local image paths for attachment", () => {
82
+ expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/icon-photo-default.png"))).toEqual([
83
+ "/tmp/icon-photo-default.png",
84
+ ]);
85
+ expect(extractBracketedImagePastePaths(bracketedPaste("C:\\Users\\me\\icon-photo-default.png"))).toEqual([
86
+ "C:\\Users\\me\\icon-photo-default.png",
87
+ ]);
88
+ });
89
+ });
90
+
69
91
  describe("CustomEditor space-hold push-to-talk", () => {
70
92
  beforeAll(async () => {
71
93
  await initTheme();
@@ -1,3 +1,4 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
1
2
  import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
3
  import type { AppKeybinding } from "../../config/keybindings";
3
4
  import { isSettingsInitialized, settings } from "../../config/settings";
@@ -63,6 +64,9 @@ const BRACKETED_PASTE_END = "\x1b[201~";
63
64
  const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
64
65
  const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
65
66
  const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
67
+ const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
68
+ const FILE_URI_REGEX = /^file:\/\//i;
69
+ const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
66
70
 
67
71
  /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
68
72
  * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
@@ -117,6 +121,12 @@ function normalizePastedImagePath(path: string): string {
117
121
  return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
118
122
  }
119
123
 
124
+ function isExplicitPastedImagePath(path: string): boolean {
125
+ if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
126
+ if (URI_SCHEME_REGEX.test(path)) return false;
127
+ return path.includes("/") || path.includes("\\");
128
+ }
129
+
120
130
  export function extractBracketedImagePastePaths(data: string): string[] | undefined {
121
131
  if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
122
132
  const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
@@ -138,7 +148,7 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
138
148
  if (boundaryEnd === undefined) continue;
139
149
 
140
150
  const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
141
- if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
151
+ if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
142
152
  paths.push(path);
143
153
 
144
154
  segmentStart = boundaryEnd;
@@ -163,6 +173,24 @@ export function extractBracketedImagePastePath(data: string): string | undefined
163
173
  export class CustomEditor extends Editor {
164
174
  imageLinks?: readonly (string | undefined)[];
165
175
 
176
+ /** Draft images pasted into the composer, consumed on submit. Co-located with
177
+ * {@link imageLinks} so every piece of draft-image state lives on the editor. */
178
+ pendingImages: ImageContent[] = [];
179
+ /** Per-image source links (file:// targets) parallel to {@link pendingImages};
180
+ * `undefined` entries are images without a backing reference yet. */
181
+ pendingImageLinks: (string | undefined)[] = [];
182
+
183
+ /** Clear the composer draft: optionally commit `historyText` to history, then
184
+ * reset the editor text and all pending draft-image state. The shared tail of
185
+ * every "message submitted" path; pass no argument for a plain discard. */
186
+ clearDraft(historyText?: string): void {
187
+ if (historyText !== undefined) this.addToHistory(historyText);
188
+ this.setText("");
189
+ this.imageLinks = undefined;
190
+ this.pendingImages = [];
191
+ this.pendingImageLinks = [];
192
+ }
193
+
166
194
  /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token
167
195
  * instead of corrupting `[Paste #1, +30 lines]` into plain text. */
168
196
  override atomicTokenPattern = PLACEHOLDER_REGEX;
@@ -26,6 +26,7 @@ import { Settings } from "../../../config/settings";
26
26
  import { DynamicBorder } from "../../../modes/components/dynamic-border";
27
27
  import { theme } from "../../../modes/theme/theme";
28
28
  import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
29
+ import { handleTabSwitchKey, padLinesToHeight } from "../selector-helpers";
29
30
  import { ExtensionList } from "./extension-list";
30
31
  import { InspectorPanel } from "./inspector-panel";
31
32
  import {
@@ -145,13 +146,8 @@ export class ExtensionDashboard extends Container {
145
146
  }
146
147
  const lines = super.render(width);
147
148
  // Pad to the full viewport so the dashboard covers the screen instead of
148
- // letting the transcript peek through below it. Copy before padding — the
149
- // container's render result is component-owned and must not be mutated.
150
- const rows = this.#terminalRows();
151
- if (lines.length >= rows) return lines;
152
- const padded = lines.slice();
153
- while (padded.length < rows) padded.push("");
154
- return padded;
149
+ // letting the transcript peek through below it.
150
+ return padLinesToHeight(lines, this.#terminalRows());
155
151
  }
156
152
 
157
153
  #buildLayout(): void {
@@ -339,12 +335,7 @@ export class ExtensionDashboard extends Container {
339
335
  }
340
336
 
341
337
  // Tab/Shift+Tab or Left/Right: Cycle through tabs
342
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
343
- this.#switchTab(1);
344
- return;
345
- }
346
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
347
- this.#switchTab(-1);
338
+ if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) {
348
339
  return;
349
340
  }
350
341
 
@@ -5,18 +5,11 @@
5
5
  * that toggles the entire provider. All items below are dimmed when the
6
6
  * master switch is off.
7
7
  */
8
- import {
9
- type Component,
10
- extractPrintableText,
11
- matchesKey,
12
- padding,
13
- ScrollView,
14
- truncateToWidth,
15
- visibleWidth,
16
- } from "@oh-my-pi/pi-tui";
8
+ import { type Component, matchesKey, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
17
9
  import { isProviderEnabled } from "../../../discovery";
18
10
  import { theme } from "../../../modes/theme/theme";
19
11
  import { matchesSelectDown, matchesSelectUp } from "../../utils/keybinding-matchers";
12
+ import { clampSelection, contentRowWidth, renderScrollableList, searchableChar } from "../selector-helpers";
20
13
  import { applyFilter } from "./state-manager";
21
14
  import type { Extension, ExtensionKind, ExtensionState } from "./types";
22
15
 
@@ -136,8 +129,7 @@ export class ExtensionList implements Component {
136
129
  const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length);
137
130
 
138
131
  // Reserve the rightmost column for the scrollbar when overflowing
139
- const overflow = this.#listItems.length > this.#maxVisible;
140
- const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
132
+ const rowWidth = contentRowWidth(width, this.#listItems.length, this.#maxVisible);
141
133
 
142
134
  // Render visible items
143
135
  const rows: string[] = [];
@@ -154,14 +146,13 @@ export class ExtensionList implements Component {
154
146
  }
155
147
  }
156
148
 
157
- const sv = new ScrollView(rows, {
158
- height: rows.length,
159
- scrollbar: "auto",
160
- totalRows: this.#listItems.length,
161
- theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
162
- });
163
- sv.setScrollOffset(this.#scrollOffset);
164
- lines.push(...sv.render(width));
149
+ lines.push(
150
+ ...renderScrollableList(rows, {
151
+ width,
152
+ totalRows: this.#listItems.length,
153
+ scrollOffset: this.#scrollOffset,
154
+ }),
155
+ );
165
156
 
166
157
  return lines;
167
158
  }
@@ -391,21 +382,9 @@ export class ExtensionList implements Component {
391
382
  }
392
383
 
393
384
  #clampSelection(): void {
394
- if (this.#listItems.length === 0) {
395
- this.#selectedIndex = 0;
396
- this.#scrollOffset = 0;
397
- return;
398
- }
399
-
400
- this.#selectedIndex = Math.min(this.#selectedIndex, this.#listItems.length - 1);
401
- this.#selectedIndex = Math.max(0, this.#selectedIndex);
402
-
403
- // Adjust scroll offset
404
- if (this.#selectedIndex < this.#scrollOffset) {
405
- this.#scrollOffset = this.#selectedIndex;
406
- } else if (this.#selectedIndex >= this.#scrollOffset + this.#maxVisible) {
407
- this.#scrollOffset = this.#selectedIndex - this.#maxVisible + 1;
408
- }
385
+ const next = clampSelection(this.#selectedIndex, this.#scrollOffset, this.#listItems.length, this.#maxVisible);
386
+ this.#selectedIndex = next.selectedIndex;
387
+ this.#scrollOffset = next.scrollOffset;
409
388
  }
410
389
 
411
390
  handleInput(data: string): void {
@@ -462,16 +441,9 @@ export class ExtensionList implements Component {
462
441
  }
463
442
 
464
443
  // Printable characters -> search
465
- const printableText = extractPrintableText(data);
466
- if (printableText && printableText.length === 1) {
467
- const printableCharCode = printableText.charCodeAt(0);
468
- if (printableCharCode > 32 && printableCharCode < 127) {
469
- if (printableText === "j" || printableText === "k") {
470
- return;
471
- }
472
- this.setSearchQuery(this.#searchQuery + printableText);
473
- return;
474
- }
444
+ const char = searchableChar(data);
445
+ if (char !== null) {
446
+ this.setSearchQuery(this.#searchQuery + char);
475
447
  }
476
448
  }
477
449
 
@@ -142,7 +142,8 @@ export class FooterComponent implements Component {
142
142
  // After compaction, tokens are unknown until the next LLM response.
143
143
  const contextUsage = this.session.getContextUsage();
144
144
  const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
145
- const contextPercentValue = contextUsage?.percent ?? 0;
145
+ const contextTokens = contextUsage?.tokens ?? 0;
146
+ const contextPercentValue = contextWindow > 0 ? (contextUsage?.percent ?? 0) : null;
146
147
 
147
148
  // Replace home directory with ~
148
149
  let pwd = shortenPath(getProjectDir());
@@ -186,8 +187,8 @@ export class FooterComponent implements Component {
186
187
  // Colorize context percentage based on usage
187
188
  let contextPercentStr: string;
188
189
  const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
189
- const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow)}${autoIndicator}`;
190
- if (contextUsage) {
190
+ const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow, contextTokens)}${autoIndicator}`;
191
+ if (contextUsage && contextPercentValue !== null) {
191
192
  const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
192
193
  contextPercentStr =
193
194
  color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
@@ -5,7 +5,6 @@ import {
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
8
- ScrollView,
9
8
  Spacer,
10
9
  Text,
11
10
  truncateToWidth,
@@ -22,6 +21,7 @@ import {
22
21
  import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
23
22
  import { DynamicBorder } from "./dynamic-border";
24
23
  import { rawKeyHint } from "./keybinding-hints";
24
+ import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers";
25
25
 
26
26
  /** Visible result rows; also the jump distance for PageUp/PageDown. */
27
27
  const MAX_VISIBLE = 10;
@@ -110,14 +110,9 @@ class HistoryResultsList implements Component {
110
110
  const cursorSymbol = `${theme.nav.cursor} `;
111
111
  const gutterWidth = visibleWidth(cursorSymbol);
112
112
 
113
- const startIndex = Math.max(
114
- 0,
115
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#results.length - this.#maxVisible),
116
- );
117
- const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length);
113
+ const { startIndex, endIndex } = centeredWindow(this.#selectedIndex, this.#results.length, this.#maxVisible);
118
114
 
119
- const overflow = this.#results.length > this.#maxVisible;
120
- const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
115
+ const rowWidth = contentRowWidth(width, this.#results.length, this.#maxVisible);
121
116
  const rows: string[] = [];
122
117
 
123
118
  for (let i = startIndex; i < endIndex; i++) {
@@ -148,14 +143,7 @@ class HistoryResultsList implements Component {
148
143
  );
149
144
  }
150
145
 
151
- const sv = new ScrollView(rows, {
152
- height: rows.length,
153
- scrollbar: "auto",
154
- totalRows: this.#results.length,
155
- theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
156
- });
157
- sv.setScrollOffset(startIndex);
158
- lines.push(...sv.render(width));
146
+ lines.push(...renderScrollableList(rows, { width, totalRows: this.#results.length, scrollOffset: startIndex }));
159
147
  return lines;
160
148
  }
161
149
  }