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

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 (129) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli.js +5547 -2759
  3. package/dist/types/config/settings-schema.d.ts +44 -14
  4. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
  5. package/dist/types/eval/backend-helpers.d.ts +23 -0
  6. package/dist/types/eval/executor-base.d.ts +118 -0
  7. package/dist/types/eval/index.d.ts +2 -0
  8. package/dist/types/eval/jl/executor.d.ts +44 -0
  9. package/dist/types/eval/jl/index.d.ts +11 -0
  10. package/dist/types/eval/jl/kernel.d.ts +28 -0
  11. package/dist/types/eval/jl/prelude.d.ts +1 -0
  12. package/dist/types/eval/jl/runtime.d.ts +22 -0
  13. package/dist/types/eval/kernel-base.d.ts +105 -0
  14. package/dist/types/eval/py/kernel.d.ts +3 -61
  15. package/dist/types/eval/rb/executor.d.ts +77 -0
  16. package/dist/types/eval/rb/index.d.ts +11 -0
  17. package/dist/types/eval/rb/kernel.d.ts +31 -0
  18. package/dist/types/eval/rb/prelude.d.ts +1 -0
  19. package/dist/types/eval/rb/runtime.d.ts +23 -0
  20. package/dist/types/eval/runtime-env.d.ts +24 -0
  21. package/dist/types/eval/types.d.ts +3 -3
  22. package/dist/types/extensibility/extensions/runner.d.ts +2 -15
  23. package/dist/types/extensibility/hooks/loader.d.ts +1 -25
  24. package/dist/types/extensibility/session-handler-types.d.ts +23 -0
  25. package/dist/types/jsonrpc/message-framing.d.ts +35 -0
  26. package/dist/types/mnemopi/embed-client.d.ts +7 -24
  27. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +11 -0
  29. package/dist/types/modes/components/selector-helpers.d.ts +53 -0
  30. package/dist/types/modes/interactive-mode.d.ts +0 -2
  31. package/dist/types/modes/theme/theme.d.ts +8 -1
  32. package/dist/types/modes/types.d.ts +0 -2
  33. package/dist/types/modes/utils/copy-targets.d.ts +1 -1
  34. package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
  35. package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +2 -2
  38. package/dist/types/stt/asr-client.d.ts +3 -29
  39. package/dist/types/subprocess/worker-client.d.ts +149 -0
  40. package/dist/types/subprocess/worker-runtime.d.ts +107 -0
  41. package/dist/types/tiny/title-client.d.ts +14 -34
  42. package/dist/types/tools/eval-backends.d.ts +6 -3
  43. package/dist/types/tools/eval-render.d.ts +6 -5
  44. package/dist/types/tools/eval.d.ts +13 -15
  45. package/dist/types/tools/index.d.ts +3 -3
  46. package/dist/types/tts/tts-client.d.ts +3 -28
  47. package/dist/types/tui/code-cell.d.ts +7 -0
  48. package/dist/types/utils/file-display-mode.d.ts +1 -1
  49. package/dist/types/web/parallel.d.ts +6 -0
  50. package/package.json +12 -12
  51. package/src/config/settings-schema.ts +50 -18
  52. package/src/config/settings.ts +5 -0
  53. package/src/dap/client.ts +13 -107
  54. package/src/eval/__tests__/julia-prelude.test.ts +77 -0
  55. package/src/eval/backend-helpers.ts +48 -0
  56. package/src/eval/executor-base.ts +425 -0
  57. package/src/eval/index.ts +2 -0
  58. package/src/eval/jl/executor.ts +540 -0
  59. package/src/eval/jl/index.ts +54 -0
  60. package/src/eval/jl/kernel.ts +235 -0
  61. package/src/eval/jl/prelude.jl +930 -0
  62. package/src/eval/jl/prelude.ts +3 -0
  63. package/src/eval/jl/runner.jl +634 -0
  64. package/src/eval/jl/runtime.ts +118 -0
  65. package/src/eval/js/index.ts +3 -14
  66. package/src/eval/kernel-base.ts +569 -0
  67. package/src/eval/py/executor.ts +43 -252
  68. package/src/eval/py/index.ts +9 -20
  69. package/src/eval/py/kernel.ts +29 -544
  70. package/src/eval/rb/executor.ts +504 -0
  71. package/src/eval/rb/index.ts +54 -0
  72. package/src/eval/rb/kernel.ts +230 -0
  73. package/src/eval/rb/prelude.rb +721 -0
  74. package/src/eval/rb/prelude.ts +3 -0
  75. package/src/eval/rb/runner.rb +474 -0
  76. package/src/eval/rb/runtime.ts +132 -0
  77. package/src/eval/runtime-env.ts +104 -0
  78. package/src/eval/types.ts +3 -3
  79. package/src/extensibility/extensions/runner.ts +4 -11
  80. package/src/extensibility/hooks/loader.ts +3 -21
  81. package/src/extensibility/session-handler-types.ts +21 -0
  82. package/src/internal-urls/docs-index.generated.txt +1 -1
  83. package/src/jsonrpc/message-framing.ts +142 -0
  84. package/src/lsp/client.ts +13 -109
  85. package/src/mnemopi/embed-client.ts +43 -198
  86. package/src/modes/components/agent-dashboard.ts +17 -40
  87. package/src/modes/components/chat-transcript-builder.ts +18 -102
  88. package/src/modes/components/custom-editor.ts +19 -0
  89. package/src/modes/components/extensions/extension-dashboard.ts +4 -13
  90. package/src/modes/components/extensions/extension-list.ts +16 -44
  91. package/src/modes/components/history-search.ts +4 -16
  92. package/src/modes/components/selector-helpers.ts +129 -0
  93. package/src/modes/components/settings-selector.ts +7 -5
  94. package/src/modes/components/tree-selector.ts +13 -18
  95. package/src/modes/controllers/event-controller.ts +3 -9
  96. package/src/modes/controllers/input-controller.ts +32 -54
  97. package/src/modes/interactive-mode.ts +5 -7
  98. package/src/modes/theme/theme.ts +35 -0
  99. package/src/modes/types.ts +0 -2
  100. package/src/modes/utils/copy-targets.ts +3 -2
  101. package/src/modes/utils/interactive-context-helpers.ts +27 -0
  102. package/src/modes/utils/transcript-render-helpers.ts +157 -0
  103. package/src/modes/utils/ui-helpers.ts +21 -126
  104. package/src/prompts/system/system-prompt.md +10 -10
  105. package/src/prompts/tools/bash.md +0 -1
  106. package/src/prompts/tools/eval.md +6 -4
  107. package/src/prompts/tools/find.md +0 -4
  108. package/src/prompts/tools/read.md +1 -2
  109. package/src/prompts/tools/replace.md +1 -1
  110. package/src/prompts/tools/search.md +0 -1
  111. package/src/sdk.ts +13 -7
  112. package/src/session/agent-session.ts +9 -7
  113. package/src/stt/asr-client.ts +35 -215
  114. package/src/stt/asr-worker.ts +29 -181
  115. package/src/subprocess/worker-client.ts +297 -0
  116. package/src/subprocess/worker-runtime.ts +277 -0
  117. package/src/task/executor.ts +4 -4
  118. package/src/tiny/title-client.ts +53 -219
  119. package/src/tiny/worker.ts +29 -180
  120. package/src/tools/eval-backends.ts +10 -3
  121. package/src/tools/eval-render.ts +17 -8
  122. package/src/tools/eval.ts +187 -22
  123. package/src/tools/index.ts +51 -28
  124. package/src/tts/tts-client.ts +38 -206
  125. package/src/tts/tts-worker.ts +23 -97
  126. package/src/tui/code-cell.ts +12 -1
  127. package/src/utils/file-display-mode.ts +2 -3
  128. package/src/web/parallel.ts +43 -42
  129. package/src/web/search/providers/parallel.ts +10 -99
@@ -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") {
@@ -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";
@@ -163,6 +164,24 @@ export function extractBracketedImagePastePath(data: string): string | undefined
163
164
  export class CustomEditor extends Editor {
164
165
  imageLinks?: readonly (string | undefined)[];
165
166
 
167
+ /** Draft images pasted into the composer, consumed on submit. Co-located with
168
+ * {@link imageLinks} so every piece of draft-image state lives on the editor. */
169
+ pendingImages: ImageContent[] = [];
170
+ /** Per-image source links (file:// targets) parallel to {@link pendingImages};
171
+ * `undefined` entries are images without a backing reference yet. */
172
+ pendingImageLinks: (string | undefined)[] = [];
173
+
174
+ /** Clear the composer draft: optionally commit `historyText` to history, then
175
+ * reset the editor text and all pending draft-image state. The shared tail of
176
+ * every "message submitted" path; pass no argument for a plain discard. */
177
+ clearDraft(historyText?: string): void {
178
+ if (historyText !== undefined) this.addToHistory(historyText);
179
+ this.setText("");
180
+ this.imageLinks = undefined;
181
+ this.pendingImages = [];
182
+ this.pendingImageLinks = [];
183
+ }
184
+
166
185
  /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token
167
186
  * instead of corrupting `[Paste #1, +30 lines]` into plain text. */
168
187
  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
 
@@ -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
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Shared scaffolding for the TUI selector/list/dashboard components: viewport
3
+ * windowing, scrollbar-aware row widths, ScrollView rendering, selection
4
+ * clamping, search-character classification, tab-cycling keys, and full-screen
5
+ * padding. Behaviour is identical to the per-component copies these helpers
6
+ * replace.
7
+ */
8
+ import { extractPrintableText, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
9
+ import { theme } from "../theme/theme";
10
+
11
+ /**
12
+ * Render `rows` through a {@link ScrollView} with the shared list theme (muted
13
+ * track / accent thumb) and an "auto" scrollbar, positioned at `scrollOffset`.
14
+ * Returns the rendered lines for the caller to append to its output.
15
+ */
16
+ export function renderScrollableList(
17
+ rows: readonly string[],
18
+ options: { width: number; totalRows: number; scrollOffset: number },
19
+ ): readonly string[] {
20
+ const sv = new ScrollView(rows, {
21
+ height: rows.length,
22
+ scrollbar: "auto",
23
+ totalRows: options.totalRows,
24
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
25
+ });
26
+ sv.setScrollOffset(options.scrollOffset);
27
+ return sv.render(options.width);
28
+ }
29
+
30
+ /**
31
+ * Center a viewport window of `maxVisible` rows on `selectedIndex` within a
32
+ * list of `total` rows, clamped to valid bounds. Used by the selection-centered
33
+ * list panes (history search, tree selector).
34
+ */
35
+ export function centeredWindow(
36
+ selectedIndex: number,
37
+ total: number,
38
+ maxVisible: number,
39
+ ): { startIndex: number; endIndex: number } {
40
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
41
+ const endIndex = Math.min(startIndex + maxVisible, total);
42
+ return { startIndex, endIndex };
43
+ }
44
+
45
+ /**
46
+ * Width available for row content, reserving the rightmost column for the
47
+ * scrollbar when the list overflows its visible window.
48
+ */
49
+ export function contentRowWidth(width: number, total: number, maxVisible: number): number {
50
+ const overflow = total > maxVisible;
51
+ return Math.max(0, width - (overflow ? 1 : 0));
52
+ }
53
+
54
+ /**
55
+ * Clamp `selectedIndex` into `[0, total)` and nudge `scrollOffset` so the
56
+ * selection stays within the visible window of `maxVisible` rows. Returns the
57
+ * adjusted pair; on an empty list both reset to 0.
58
+ */
59
+ export function clampSelection(
60
+ selectedIndex: number,
61
+ scrollOffset: number,
62
+ total: number,
63
+ maxVisible: number,
64
+ ): { selectedIndex: number; scrollOffset: number } {
65
+ if (total === 0) {
66
+ return { selectedIndex: 0, scrollOffset: 0 };
67
+ }
68
+
69
+ const selected = Math.max(0, Math.min(selectedIndex, total - 1));
70
+
71
+ let scroll = scrollOffset;
72
+ if (selected < scroll) {
73
+ scroll = selected;
74
+ } else if (selected >= scroll + maxVisible) {
75
+ scroll = selected - maxVisible + 1;
76
+ }
77
+
78
+ return { selectedIndex: selected, scrollOffset: scroll };
79
+ }
80
+
81
+ /**
82
+ * Classify a key event for search-query text entry. Returns the single
83
+ * printable character to append to the query, or `null` when the key is not a
84
+ * searchable character: non-printable, multi-byte, or a reserved `j`/`k`
85
+ * navigation key.
86
+ */
87
+ export function searchableChar(data: string): string | null {
88
+ const printableText = extractPrintableText(data);
89
+ if (printableText && printableText.length === 1) {
90
+ const printableCharCode = printableText.charCodeAt(0);
91
+ if (printableCharCode > 32 && printableCharCode < 127) {
92
+ if (printableText === "j" || printableText === "k") {
93
+ return null;
94
+ }
95
+ return printableText;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Handle the shared tab-cycling keys: Tab/Right advance to the next tab,
103
+ * Shift+Tab/Left to the previous. Invokes `switchTab` with the direction and
104
+ * returns true when the key was consumed.
105
+ */
106
+ export function handleTabSwitchKey(data: string, switchTab: (direction: 1 | -1) => void): boolean {
107
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
108
+ switchTab(1);
109
+ return true;
110
+ }
111
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
112
+ switchTab(-1);
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Pad `lines` with blank rows up to `rows` so a full-screen overlay covers the
120
+ * viewport instead of letting the transcript peek through below it. Copies
121
+ * before padding — the source array may be component-owned and must not be
122
+ * mutated.
123
+ */
124
+ export function padLinesToHeight(lines: readonly string[], rows: number): readonly string[] {
125
+ if (lines.length >= rows) return lines;
126
+ const padded = lines.slice();
127
+ while (padded.length < rows) padded.push("");
128
+ return padded;
129
+ }
@@ -473,15 +473,17 @@ export class SettingsSelectorComponent implements Component {
473
473
  return true;
474
474
  }
475
475
 
476
- if (event.wheel !== null) {
477
- list?.handleWheel(event.wheel);
478
- return true;
479
- }
480
-
481
476
  const tabLine = event.row - this.#tabRowStart;
482
477
  const overTabs = tabLine >= 0 && tabLine < this.#tabRowCount;
483
478
  const overContent = contentLine >= 0 && contentLine < this.#contentRowCount;
484
479
 
480
+ if (event.wheel !== null) {
481
+ if (overContent) {
482
+ list?.handleWheelAt(event.wheel, contentLine, innerCol);
483
+ }
484
+ return true;
485
+ }
486
+
485
487
  if (event.motion) {
486
488
  const hovered = overTabs ? this.#tabBar.tabAt(tabLine, innerCol) : undefined;
487
489
  this.#tabBar.setHoverTab(hovered && !hovered.muted ? hovered.id : null);
@@ -6,7 +6,6 @@ import {
6
6
  fuzzyMatch,
7
7
  Input,
8
8
  matchesKey,
9
- ScrollView,
10
9
  Spacer,
11
10
  Text,
12
11
  TruncatedText,
@@ -20,6 +19,7 @@ import { shortenPath } from "../../tools/render-utils";
20
19
  import { toPathList } from "../../tools/search";
21
20
  import { canonicalizeMessage } from "../../utils/thinking-display";
22
21
  import { DynamicBorder } from "./dynamic-border";
22
+ import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers";
23
23
 
24
24
  /** Gutter info: position (displayIndent where connector was) and whether to show │ */
25
25
  interface GutterInfo {
@@ -475,14 +475,11 @@ class TreeList implements Component {
475
475
  return lines;
476
476
  }
477
477
 
478
- const startIndex = Math.max(
479
- 0,
480
- Math.min(
481
- this.#selectedIndex - Math.floor(this.maxVisibleLines / 2),
482
- this.#filteredNodes.length - this.maxVisibleLines,
483
- ),
478
+ const { startIndex, endIndex } = centeredWindow(
479
+ this.#selectedIndex,
480
+ this.#filteredNodes.length,
481
+ this.maxVisibleLines,
484
482
  );
485
- const endIndex = Math.min(startIndex + this.maxVisibleLines, this.#filteredNodes.length);
486
483
 
487
484
  // Cap the per-row gutter prefix so a content budget is always preserved.
488
485
  // Each indent level renders as 3 cells; deep branching would otherwise eat the
@@ -494,8 +491,7 @@ class TreeList implements Component {
494
491
  const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
495
492
  const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
496
493
 
497
- const overflow = this.#filteredNodes.length > this.maxVisibleLines;
498
- const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
494
+ const rowWidth = contentRowWidth(width, this.#filteredNodes.length, this.maxVisibleLines);
499
495
  const rows: string[] = [];
500
496
 
501
497
  for (let i = startIndex; i < endIndex; i++) {
@@ -584,14 +580,13 @@ class TreeList implements Component {
584
580
  rows.push(truncateToWidth(line, rowWidth));
585
581
  }
586
582
 
587
- const sv = new ScrollView(rows, {
588
- height: rows.length,
589
- scrollbar: "auto",
590
- totalRows: this.#filteredNodes.length,
591
- theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
592
- });
593
- sv.setScrollOffset(startIndex);
594
- lines.push(...sv.render(width));
583
+ lines.push(
584
+ ...renderScrollableList(rows, {
585
+ width,
586
+ totalRows: this.#filteredNodes.length,
587
+ scrollOffset: startIndex,
588
+ }),
589
+ );
595
590
 
596
591
  const filterLabel = this.#getFilterLabel();
597
592
  if (filterLabel) {
@@ -5,7 +5,7 @@ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
5
5
  import { extractTextContent } from "../../commit/utils";
6
6
  import { settings } from "../../config/settings";
7
7
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
8
- import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
+ import type { AssistantMessageComponent } from "../../modes/components/assistant-message";
9
9
  import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
10
10
  import {
11
11
  ReadToolGroupComponent,
@@ -25,6 +25,7 @@ import type { ResolveToolDetails } from "../../tools/resolve";
25
25
  import { vocalizer } from "../../tts/vocalizer";
26
26
  import { canonicalizeMessage } from "../../utils/thinking-display";
27
27
  import { interruptHint } from "../shared";
28
+ import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
28
29
  import { StreamingRevealController } from "./streaming-reveal";
29
30
  import { ToolArgsRevealController } from "./tool-args-reveal";
30
31
 
@@ -332,14 +333,7 @@ export class EventController {
332
333
  this.ctx.ui.requestRender();
333
334
  } else if (event.message.role === "assistant") {
334
335
  this.#lastVisibleBlockCount = 0;
335
- this.ctx.streamingComponent = new AssistantMessageComponent(
336
- undefined,
337
- this.ctx.hideThinkingBlock,
338
- () => this.ctx.ui.requestRender(),
339
- this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
340
- this.ctx.ui.imageBudget,
341
- this.ctx.proseOnlyThinking,
342
- );
336
+ this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
343
337
  this.ctx.streamingMessage = event.message;
344
338
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
345
339
  this.#streamingReveal.begin(this.ctx.streamingComponent, this.ctx.streamingMessage);