@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
package/src/lsp/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
2
3
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
3
4
  import { applyWorkspaceEdit } from "./edits";
@@ -147,6 +148,7 @@ const CLIENT_CAPABILITIES = {
147
148
  failureHandling: "textOnlyTransactional",
148
149
  },
149
150
  configuration: true,
151
+ workspaceFolders: true,
150
152
  symbol: {
151
153
  dynamicRegistration: false,
152
154
  symbolKind: {
@@ -318,6 +320,22 @@ async function startMessageReader(client: LspClient): Promise<void> {
318
320
  }
319
321
  }
320
322
 
323
+ /**
324
+ * Build the workspace folder list advertised to the server. Identical shape
325
+ * for `initialize` params and `workspace/workspaceFolders` server requests.
326
+ */
327
+ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: string }> {
328
+ return [{ uri: fileToUri(client.cwd), name: path.basename(client.cwd) || "workspace" }];
329
+ }
330
+
331
+ /**
332
+ * Handle workspace/workspaceFolders requests from the server.
333
+ */
334
+ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
335
+ if (typeof message.id !== "number") return;
336
+ await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
337
+ }
338
+
321
339
  /**
322
340
  * Handle workspace/configuration requests from the server.
323
341
  */
@@ -364,6 +382,10 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
364
382
  await handleConfigurationRequest(client, message);
365
383
  return;
366
384
  }
385
+ if (message.method === "workspace/workspaceFolders") {
386
+ await handleWorkspaceFoldersRequest(client, message);
387
+ return;
388
+ }
367
389
  if (message.method === "workspace/applyEdit") {
368
390
  await handleApplyEditRequest(client, message);
369
391
  return;
@@ -412,7 +434,60 @@ async function sendResponse(
412
434
  /** Timeout for warmup initialize requests (5 seconds) */
413
435
  export const WARMUP_TIMEOUT_MS = 5000;
414
436
 
415
- /** Max time to wait for the server to report project loading completion via $/progress */
437
+ /** Max time to poll rust-analyzer after progress ends but before Cargo workspaces are ready. */
438
+ const RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS = 5_000;
439
+ const RUST_ANALYZER_WORKSPACE_READY_POLL_MS = 100;
440
+ const RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS = 2_000;
441
+ const rustAnalyzerReadyClients = new WeakSet<LspClient>();
442
+
443
+ function commandBasename(command: string): string {
444
+ const slash = command.lastIndexOf("/");
445
+ const backslash = command.lastIndexOf("\\");
446
+ const separator = Math.max(slash, backslash);
447
+ return separator === -1 ? command : command.slice(separator + 1);
448
+ }
449
+
450
+ function isRustAnalyzerClient(client: LspClient): boolean {
451
+ return (
452
+ commandBasename(client.config.command) === "rust-analyzer" ||
453
+ (client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
454
+ );
455
+ }
456
+
457
+ function isRustAnalyzerStatusTimeout(err: unknown): boolean {
458
+ return err instanceof Error && err.message.startsWith("LSP request rust-analyzer/analyzerStatus timed out after ");
459
+ }
460
+
461
+ async function waitForRustAnalyzerWorkspace(client: LspClient, signal?: AbortSignal): Promise<void> {
462
+ if (rustAnalyzerReadyClients.has(client)) {
463
+ return;
464
+ }
465
+ const started = Date.now();
466
+ const deadline = started + RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS;
467
+ while (true) {
468
+ throwIfAborted(signal);
469
+ let status: unknown;
470
+ try {
471
+ status = await sendRequest(client, "rust-analyzer/analyzerStatus", {}, signal, 1_000);
472
+ } catch (err) {
473
+ if (!isRustAnalyzerStatusTimeout(err) || Date.now() >= deadline) {
474
+ return;
475
+ }
476
+ await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
477
+ continue;
478
+ }
479
+ const ready = typeof status === "string" && !status.startsWith("No workspaces");
480
+ if (ready && Date.now() - started >= RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS) {
481
+ rustAnalyzerReadyClients.add(client);
482
+ return;
483
+ }
484
+ if (Date.now() >= deadline) {
485
+ return;
486
+ }
487
+ await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
488
+ }
489
+ }
490
+
416
491
  const PROJECT_LOAD_TIMEOUT_MS = 15_000;
417
492
 
418
493
  /** Max time to wait for graceful LSP shutdown and process exit. */
@@ -530,7 +605,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
530
605
  rootPath: cwd,
531
606
  capabilities: CLIENT_CAPABILITIES,
532
607
  initializationOptions: config.initOptions ?? {},
533
- workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
608
+ workspaceFolders: currentWorkspaceFolders(client),
534
609
  },
535
610
  undefined, // signal
536
611
  initTimeoutMs,
@@ -635,6 +710,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
635
710
  ? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
636
711
  : []),
637
712
  ]);
713
+ if (isRustAnalyzerClient(client)) {
714
+ await waitForRustAnalyzerWorkspace(client, signal);
715
+ }
638
716
  }
639
717
 
640
718
  /**
package/src/lsp/index.ts CHANGED
@@ -304,6 +304,32 @@ const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
304
304
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
305
305
  const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
306
306
  const WORKSPACE_SYMBOL_LIMIT = 200;
307
+ const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
308
+ "definition",
309
+ "type_definition",
310
+ "implementation",
311
+ "references",
312
+ "rename",
313
+ "hover",
314
+ ]);
315
+
316
+ const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
317
+
318
+ function hasRustWorkspaceAncestor(filePath: string): boolean {
319
+ let dir = path.dirname(filePath);
320
+ while (true) {
321
+ for (const marker of RUST_WORKSPACE_MARKERS) {
322
+ if (fs.existsSync(path.join(dir, marker))) {
323
+ return true;
324
+ }
325
+ }
326
+ const parent = path.dirname(dir);
327
+ if (parent === dir) {
328
+ return false;
329
+ }
330
+ dir = parent;
331
+ }
332
+ }
307
333
 
308
334
  function limitDiagnosticMessages(messages: string[]): string[] {
309
335
  if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
@@ -1940,10 +1966,21 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1940
1966
  try {
1941
1967
  const client = await getOrCreateClient(serverConfig, this.session.cwd);
1942
1968
  const targetFile = resolvedFile;
1969
+ const isRustAnalyzerServer =
1970
+ serverName === "rust-analyzer" ||
1971
+ path.basename(serverConfig.command) === "rust-analyzer" ||
1972
+ (serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
1973
+ const needsProjectIndex =
1974
+ targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
1975
+ const rustWorkspaceWait =
1976
+ needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
1943
1977
 
1944
1978
  if (targetFile) {
1945
1979
  await ensureFileOpen(client, targetFile, signal);
1946
1980
  }
1981
+ if (rustWorkspaceWait) {
1982
+ await waitForProjectLoaded(client, signal);
1983
+ }
1947
1984
 
1948
1985
  // For project-aware servers, references/rename/definition without a `symbol`
1949
1986
  // silently falls back to the first non-whitespace column on the line, which
@@ -1968,10 +2005,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1968
2005
 
1969
2006
  let output: string;
1970
2007
 
1971
- // Wait for project loading to complete before cross-file operations
1972
- // to ensure the server has indexed all project files.
1973
- const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
1974
- if (crossFileActions.has(action)) {
2008
+ if (needsProjectIndex && !isRustAnalyzerServer) {
1975
2009
  await waitForProjectLoaded(client, signal);
1976
2010
  }
1977
2011
 
package/src/lsp/render.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  truncateToWidth,
22
22
  } from "../tools/render-utils";
23
23
  import { renderStatusLine } from "../tui";
24
- import { CachedOutputBlock } from "../tui/output-block";
24
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
25
25
  import type { LspParams, LspToolDetails } from "./types";
26
26
 
27
27
  // =============================================================================
@@ -138,7 +138,7 @@ export function renderResult(
138
138
 
139
139
  const outputBlock = new CachedOutputBlock();
140
140
 
141
- return {
141
+ return markFramedBlockComponent({
142
142
  render(width: number): string[] {
143
143
  // Read mutable state at render time
144
144
  const { expanded, isPartial, spinnerFrame } = options;
@@ -194,7 +194,7 @@ export function renderResult(
194
194
  invalidate() {
195
195
  outputBlock.invalidate();
196
196
  },
197
- };
197
+ });
198
198
  }
199
199
 
200
200
  // =============================================================================
package/src/main.ts CHANGED
@@ -268,7 +268,7 @@ async function runInteractiveMode(
268
268
  force: forceSetupWizard,
269
269
  });
270
270
 
271
- await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
271
+ await mode.init({ suppressWelcomeIntro: resuming || setupScenes.length > 0 });
272
272
 
273
273
  if (setupScenes.length > 0) {
274
274
  await runSetupWizard(mode, setupScenes);
@@ -285,7 +285,13 @@ async function runInteractiveMode(
285
285
  })
286
286
  .catch(() => {});
287
287
 
288
- mode.renderInitialMessages(undefined, { preserveExistingChat: true });
288
+ // Cold-launch cleanup: wipe the terminal scrollback before painting the
289
+ // resumed/new transcript. The TUI's initial paint deliberately preserves
290
+ // native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
291
+ // the previous run's welcome + transcript stacked above the fresh one. Every
292
+ // in-process session load already clears via `clearTerminalHistory`; the cold
293
+ // launch is the lone path that did not.
294
+ mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
289
295
 
290
296
  for (const notify of notifs) {
291
297
  if (!notify) {
@@ -27,6 +27,7 @@ import {
27
27
  matchesKey,
28
28
  padding,
29
29
  replaceTabs,
30
+ ScrollView,
30
31
  Spacer,
31
32
  Text,
32
33
  truncateToWidth,
@@ -205,9 +206,12 @@ class AgentListPane implements Component {
205
206
  return lines;
206
207
  }
207
208
 
209
+ const overflow = this.agents.length > this.maxVisible;
210
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
208
211
  const start = this.scrollOffset;
209
212
  const end = Math.min(start + this.maxVisible, this.agents.length);
210
213
 
214
+ const rows: string[] = [];
211
215
  for (let i = start; i < end; i++) {
212
216
  const agent = this.agents[i];
213
217
  const selected = i === this.selectedIndex;
@@ -224,12 +228,17 @@ class AgentListPane implements Component {
224
228
  line = theme.fg("dim", line);
225
229
  }
226
230
 
227
- lines.push(truncateToWidth(line, width));
231
+ rows.push(truncateToWidth(line, rowWidth));
228
232
  }
229
233
 
230
- if (this.agents.length > this.maxVisible) {
231
- lines.push(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.agents.length})`));
232
- }
234
+ const sv = new ScrollView(rows, {
235
+ height: rows.length,
236
+ scrollbar: "auto",
237
+ totalRows: this.agents.length,
238
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
239
+ });
240
+ sv.setScrollOffset(this.scrollOffset);
241
+ lines.push(...sv.render(width));
233
242
 
234
243
  return lines;
235
244
  }
@@ -17,6 +17,16 @@ export class AssistantMessageComponent extends Container {
17
17
  #usageInfo?: Usage;
18
18
  #convertedKittyImages = new Map<string, ImageContent>();
19
19
  #kittyConversionsInFlight = new Set<string>();
20
+ #transcriptBlockFinalized: boolean;
21
+ /**
22
+ * When true, the turn-ending `Error: …` line for `stopReason === "error"` is
23
+ * suppressed because the same error is currently shown in the pinned banner
24
+ * above the editor (see `EventController` + `ErrorBannerComponent`). Avoids
25
+ * rendering the identical error twice (inline + banner) at the error moment.
26
+ * Restored to `false` when the banner is cleared at the next turn so the
27
+ * transcript keeps the error in history.
28
+ */
29
+ #errorPinned = false;
20
30
 
21
31
  constructor(
22
32
  message?: AssistantMessage,
@@ -26,6 +36,7 @@ export class AssistantMessageComponent extends Container {
26
36
  private readonly imageBudget?: ImageBudget,
27
37
  ) {
28
38
  super();
39
+ this.#transcriptBlockFinalized = message !== undefined;
29
40
 
30
41
  // Container for text/thinking content
31
42
  this.#contentContainer = new Container();
@@ -47,6 +58,38 @@ export class AssistantMessageComponent extends Container {
47
58
  this.hideThinkingBlock = hide;
48
59
  }
49
60
 
61
+ /**
62
+ * Toggle suppression of the inline `Error: …` line while the same error is
63
+ * pinned in the banner above the editor. Re-renders so the change is visible.
64
+ */
65
+ setErrorPinned(pinned: boolean): void {
66
+ if (this.#errorPinned === pinned) return;
67
+ this.#errorPinned = pinned;
68
+ if (this.#lastMessage) {
69
+ this.updateContent(this.#lastMessage);
70
+ }
71
+ }
72
+
73
+ isTranscriptBlockFinalized(): boolean {
74
+ return this.#transcriptBlockFinalized;
75
+ }
76
+
77
+ /**
78
+ * Assistant text/thinking streams in append-only: earlier rendered rows never
79
+ * re-layout, new content only grows the block at the bottom. The transcript
80
+ * reports this so the renderer may commit scrolled-off head rows of a long
81
+ * streamed reply to native scrollback instead of dropping them (see
82
+ * `NativeScrollbackLiveRegion#getNativeScrollbackCommitSafeEnd`). Volatile
83
+ * blocks (tool previews that collapse) intentionally do not implement this.
84
+ */
85
+ isTranscriptBlockAppendOnly(): boolean {
86
+ return true;
87
+ }
88
+
89
+ markTranscriptBlockFinalized(): void {
90
+ this.#transcriptBlockFinalized = true;
91
+ }
92
+
50
93
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
51
94
  if (!toolCallId) return;
52
95
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
@@ -224,7 +267,7 @@ export class AssistantMessageComponent extends Container {
224
267
  this.#contentContainer.addChild(new Spacer(1));
225
268
  }
226
269
  this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
227
- } else if (message.stopReason === "error") {
270
+ } else if (message.stopReason === "error" && !this.#errorPinned) {
228
271
  const errorMsg = message.errorMessage || "Unknown error";
229
272
  this.#contentContainer.addChild(new Spacer(1));
230
273
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
@@ -0,0 +1,249 @@
1
+ import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { replaceTabs } from "../../tools/render-utils";
3
+ import { highlightCode, theme } from "../theme/theme";
4
+ import type { CopyTarget } from "../utils/copy-targets";
5
+ import {
6
+ matchesSelectCancel,
7
+ matchesSelectDown,
8
+ matchesSelectPageDown,
9
+ matchesSelectPageUp,
10
+ matchesSelectUp,
11
+ } from "../utils/keybinding-matchers";
12
+ import { keyHint, rawKeyHint } from "./keybinding-hints";
13
+
14
+ /** Minimum rows reserved for the tree even on short terminals. */
15
+ const MIN_TREE_ROWS = 3;
16
+ /** Fixed chrome rows: top border, two dividers, footer, bottom border. */
17
+ const CHROME_ROWS = 5;
18
+
19
+ export interface CopySelectorCallbacks {
20
+ /** A copy target was chosen — copy its `content`. */
21
+ onPick: (target: CopyTarget) => void;
22
+ /** The picker was dismissed. */
23
+ onCancel: () => void;
24
+ }
25
+
26
+ interface FlatNode {
27
+ target: CopyTarget;
28
+ depth: number;
29
+ /** Last among its siblings (drives └─ vs ├─). */
30
+ isLast: boolean;
31
+ /** Per-ancestor flag: does ancestor at that level have a following sibling? */
32
+ ancestorHasNext: boolean[];
33
+ }
34
+
35
+ /** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
36
+ function fit(text: string, width: number): string {
37
+ if (width <= 0) return "";
38
+ const w = visibleWidth(text);
39
+ if (w === width) return text;
40
+ if (w < width) return text + padding(width - w);
41
+ const cut = truncateToWidth(text, width);
42
+ const cw = visibleWidth(cut);
43
+ return cw < width ? cut + padding(width - cw) : cut;
44
+ }
45
+
46
+ function paint(s: string): string {
47
+ return theme.fg("border", s);
48
+ }
49
+
50
+ function topBorder(width: number, title: string): string {
51
+ const box = theme.boxSharp;
52
+ const inner = Math.max(0, width - 2);
53
+ if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
54
+ const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
55
+ const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
56
+ return (
57
+ paint(box.topLeft + box.horizontal) +
58
+ theme.bold(theme.fg("accent", shown)) +
59
+ paint(box.horizontal.repeat(fillWidth) + box.topRight)
60
+ );
61
+ }
62
+
63
+ function divider(width: number): string {
64
+ const box = theme.boxSharp;
65
+ return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
66
+ }
67
+
68
+ function bottomBorder(width: number): string {
69
+ const box = theme.boxSharp;
70
+ return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
71
+ }
72
+
73
+ /** Wrap pre-styled content in vertical borders with single-column insets. */
74
+ function row(content: string, width: number): string {
75
+ const box = theme.boxSharp;
76
+ return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
77
+ }
78
+
79
+ /** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
80
+ function connectorCells(symbol: string): string {
81
+ const chars = Array.from(symbol);
82
+ return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " ");
83
+ }
84
+
85
+ /** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */
86
+ function gutterCells(hasNext: boolean): string {
87
+ return `${hasNext ? theme.tree.vertical : " "} `;
88
+ }
89
+
90
+ /**
91
+ * Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one
92
+ * outlined box: a title, the tree of copy targets (recent assistant messages
93
+ * with their code blocks nested beneath), a live preview of the highlighted
94
+ * node, and a keybinding footer. Every node copies its `content` on Enter.
95
+ */
96
+ export class CopySelectorComponent implements Component {
97
+ #roots: CopyTarget[];
98
+ #cursorId: string;
99
+ #treeRows = MIN_TREE_ROWS;
100
+ // Reused across renders to wrap preview content to the pane width.
101
+ #previewText = new Text("", 0, 0);
102
+
103
+ constructor(
104
+ roots: CopyTarget[],
105
+ private readonly callbacks: CopySelectorCallbacks,
106
+ ) {
107
+ this.#roots = roots;
108
+ this.#cursorId = roots[0]?.id ?? "";
109
+ }
110
+
111
+ invalidate(): void {}
112
+
113
+ #flatten(): FlatNode[] {
114
+ const out: FlatNode[] = [];
115
+ const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => {
116
+ nodes.forEach((target, i) => {
117
+ const isLast = i === nodes.length - 1;
118
+ out.push({ target, depth, isLast, ancestorHasNext });
119
+ if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]);
120
+ });
121
+ };
122
+ walk(this.#roots, 0, []);
123
+ return out;
124
+ }
125
+
126
+ handleInput(keyData: string): void {
127
+ if (matchesSelectCancel(keyData)) {
128
+ this.callbacks.onCancel();
129
+ return;
130
+ }
131
+
132
+ const flat = this.#flatten();
133
+ if (flat.length === 0) return;
134
+ const idx = Math.max(
135
+ 0,
136
+ flat.findIndex(n => n.target.id === this.#cursorId),
137
+ );
138
+
139
+ if (matchesSelectUp(keyData)) {
140
+ this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id;
141
+ } else if (matchesSelectDown(keyData)) {
142
+ this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id;
143
+ } else if (matchesSelectPageUp(keyData)) {
144
+ this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id;
145
+ } else if (matchesSelectPageDown(keyData)) {
146
+ this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id;
147
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
148
+ const target = flat[idx]!.target;
149
+ if (target.content !== undefined) this.callbacks.onPick(target);
150
+ }
151
+ }
152
+
153
+ #renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] {
154
+ const inner = Math.max(0, width - 4);
155
+ const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows)));
156
+ const out: string[] = [];
157
+ for (let r = 0; r < rows; r++) {
158
+ const i = start + r;
159
+ const node = flat[i];
160
+ if (!node) {
161
+ out.push(row("", width));
162
+ continue;
163
+ }
164
+ const target = node.target;
165
+ const isSelected = i === cursorIdx;
166
+
167
+ let prefix = "";
168
+ for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!);
169
+ if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch);
170
+
171
+ const cursor = isSelected ? "❯ " : " ";
172
+ const hint = target.hint ?? "";
173
+ const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
174
+ const used = visibleWidth(cursor) + visibleWidth(prefix);
175
+ const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth));
176
+ const left = isSelected
177
+ ? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain))
178
+ : cursor + theme.fg("dim", prefix) + labelPlain;
179
+ const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint));
180
+ out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width));
181
+ }
182
+ return out;
183
+ }
184
+
185
+ #renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] {
186
+ const out: string[] = [];
187
+ const hint = target?.hint;
188
+ out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width));
189
+
190
+ const contentRows = rows - 1;
191
+ if (!target || contentRows <= 0) {
192
+ while (out.length < rows) out.push(row("", width));
193
+ return out;
194
+ }
195
+
196
+ // Code/command previews are syntax-highlighted; everything else is shown
197
+ // as plain text. Both are wrapped (not hard-truncated) to the pane width.
198
+ const isCode = target.language !== undefined;
199
+ const source = isCode
200
+ ? highlightCode(replaceTabs(target.preview), target.language).join("\n")
201
+ : replaceTabs(target.preview);
202
+ this.#previewText.setText(source);
203
+ const wrapped = this.#previewText.render(Math.max(1, width - 4));
204
+
205
+ const hasMore = wrapped.length > contentRows;
206
+ const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows);
207
+ for (let k = 0; k < contentRows; k++) {
208
+ if (k < visibleCount) {
209
+ out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width));
210
+ } else if (k === visibleCount && hasMore) {
211
+ out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width));
212
+ } else {
213
+ out.push(row("", width));
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ render(width: number): string[] {
220
+ const height = process.stdout.rows || 40;
221
+ const flat = this.#flatten();
222
+ const cursorIdx = Math.max(
223
+ 0,
224
+ flat.findIndex(n => n.target.id === this.#cursorId),
225
+ );
226
+ const selected = flat[cursorIdx]?.target;
227
+
228
+ const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS);
229
+ const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2)));
230
+ this.#treeRows = treeRows;
231
+ const previewRows = Math.max(1, available - treeRows);
232
+
233
+ const footer = [
234
+ rawKeyHint("↑↓", "move"),
235
+ keyHint("tui.select.confirm", "copy"),
236
+ keyHint("tui.select.cancel", "quit"),
237
+ ].join(theme.fg("dim", " · "));
238
+
239
+ return [
240
+ topBorder(width, "Copy to clipboard"),
241
+ ...this.#renderTree(width, flat, cursorIdx, treeRows),
242
+ divider(width),
243
+ ...this.#renderPreview(width, selected, previewRows),
244
+ divider(width),
245
+ row(footer, width),
246
+ bottomBorder(width),
247
+ ];
248
+ }
249
+ }
@@ -1,6 +1,8 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
2
  import type { AppKeybinding } from "../../config/keybindings";
3
+ import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
3
4
  import { highlightMagicKeywords } from "../magic-keywords";
5
+ import { theme } from "../theme/theme";
4
6
 
5
7
  type ConfigurableEditorAction = Extract<
6
8
  AppKeybinding,
@@ -47,9 +49,19 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
47
49
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
48
50
  */
49
51
  export class CustomEditor extends Editor {
52
+ imageLinks?: readonly (string | undefined)[];
53
+
50
54
  /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" keywords as the user types
51
- * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. */
52
- decorateText = (text: string): string => highlightMagicKeywords(text);
55
+ * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
56
+ * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */
57
+ decorateText = (text: string): string =>
58
+ renderImageReferences(text, {
59
+ renderText: value => highlightMagicKeywords(value),
60
+ renderReference: (value, index) =>
61
+ imageReferenceHyperlink(value, index, this.imageLinks, label =>
62
+ theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`),
63
+ ),
64
+ });
53
65
  onEscape?: () => void;
54
66
  onClear?: () => void;
55
67
  onExit?: () => void;
@@ -0,0 +1,33 @@
1
+ import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import { getPreviewLines, TRUNCATE_LENGTHS } from "../../tools/render-utils";
3
+ import { theme } from "../theme/theme";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ /** Max lines of the error message shown in the pinned banner. */
7
+ const MAX_BANNER_LINES = 3;
8
+
9
+ /**
10
+ * A persistent error banner pinned above the editor. Unlike the transcript
11
+ * "Error: …" line (which scrolls away as the conversation grows), this stays in
12
+ * the fixed region directly above the input so a turn that ended on a provider
13
+ * error — e.g. Anthropic's "Output blocked by content filtering policy" — cannot
14
+ * be missed. It is cleared when the next turn starts.
15
+ */
16
+ export class ErrorBannerComponent extends Container {
17
+ constructor(message: string) {
18
+ super();
19
+ const lines = getPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE);
20
+ if (lines.length === 0) {
21
+ lines.push("Unknown error");
22
+ }
23
+
24
+ this.addChild(new Spacer(1));
25
+ this.addChild(new DynamicBorder(str => theme.fg("error", str)));
26
+ this.addChild(new Text(theme.bold(theme.fg("error", `${theme.status.error} ${lines[0]}`)), 1, 0));
27
+ for (const line of lines.slice(1)) {
28
+ this.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
29
+ }
30
+ this.addChild(new Text(theme.fg("dim", "Dismissed when you send your next message."), 1, 0));
31
+ this.addChild(new DynamicBorder(str => theme.fg("error", str)));
32
+ }
33
+ }