@oh-my-pi/pi-coding-agent 11.2.3 → 11.4.0

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 (102) hide show
  1. package/CHANGELOG.md +119 -4
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +41 -13
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +106 -4
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +77 -63
  56. package/src/prompts/system/plan-mode-active.md +6 -6
  57. package/src/prompts/system/system-prompt.md +2 -1
  58. package/src/prompts/tools/ask.md +2 -2
  59. package/src/prompts/tools/gemini-image.md +2 -2
  60. package/src/prompts/tools/lsp.md +2 -2
  61. package/src/prompts/tools/patch.md +1 -1
  62. package/src/prompts/tools/python.md +3 -3
  63. package/src/prompts/tools/task.md +7 -1
  64. package/src/prompts/tools/todo-write.md +2 -2
  65. package/src/prompts/tools/web-search.md +2 -2
  66. package/src/prompts/tools/write.md +2 -5
  67. package/src/sdk.ts +15 -11
  68. package/src/session/agent-session.ts +92 -34
  69. package/src/session/auth-storage.ts +2 -1
  70. package/src/session/blob-store.ts +105 -0
  71. package/src/session/session-manager.ts +107 -44
  72. package/src/task/executor.ts +19 -9
  73. package/src/task/render.ts +80 -58
  74. package/src/tools/ask.ts +28 -5
  75. package/src/tools/bash.ts +47 -39
  76. package/src/tools/browser.ts +248 -26
  77. package/src/tools/calculator.ts +42 -23
  78. package/src/tools/fetch.ts +33 -16
  79. package/src/tools/find.ts +57 -22
  80. package/src/tools/grep.ts +54 -25
  81. package/src/tools/index.ts +5 -5
  82. package/src/tools/notebook.ts +19 -6
  83. package/src/tools/path-utils.ts +26 -1
  84. package/src/tools/python.ts +20 -14
  85. package/src/tools/read.ts +21 -8
  86. package/src/tools/render-utils.ts +5 -45
  87. package/src/tools/ssh.ts +59 -53
  88. package/src/tools/submit-result.ts +2 -2
  89. package/src/tools/todo-write.ts +32 -14
  90. package/src/tools/truncate.ts +1 -1
  91. package/src/tools/write.ts +42 -26
  92. package/src/tui/code-cell.ts +1 -1
  93. package/src/tui/output-block.ts +61 -3
  94. package/src/tui/tree-list.ts +4 -4
  95. package/src/tui/utils.ts +71 -1
  96. package/src/utils/frontmatter.ts +1 -1
  97. package/src/utils/title-generator.ts +1 -1
  98. package/src/utils/tools-manager.ts +18 -2
  99. package/src/web/scrapers/osv.ts +4 -1
  100. package/src/web/scrapers/youtube.ts +1 -1
  101. package/src/web/search/index.ts +1 -1
  102. package/src/web/search/render.ts +96 -90
@@ -7,9 +7,8 @@
7
7
  import * as os from "node:os";
8
8
  import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
9
9
  import type { Theme } from "../modes/theme/theme";
10
- import { getTreeBranch } from "../tui/utils";
11
10
 
12
- export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
11
+ export { Ellipsis, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
13
12
 
14
13
  // =============================================================================
15
14
  // Standardized Display Constants
@@ -27,6 +26,10 @@ export const PREVIEW_LIMITS = {
27
26
  OUTPUT_COLLAPSED: 3,
28
27
  /** Output preview lines in expanded view */
29
28
  OUTPUT_EXPANDED: 10,
29
+ /** Max hunks shown when collapsed (edit tool) */
30
+ DIFF_COLLAPSED_HUNKS: 8,
31
+ /** Max diff lines shown when collapsed (edit tool) */
32
+ DIFF_COLLAPSED_LINES: 40,
30
33
  } as const;
31
34
 
32
35
  /** Truncation lengths for different content types */
@@ -669,52 +672,9 @@ export function wrapBrackets(text: string, theme: Theme): string {
669
672
  return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
670
673
  }
671
674
 
672
- export function replaceTabs(text: string): string {
673
- return text.replace(/\t/g, " ");
674
- }
675
-
676
675
  function pluralize(label: string, count: number): string {
677
676
  if (count === 1) return label;
678
677
  if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
679
678
  if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
680
679
  return `${label}s`;
681
680
  }
682
-
683
- // =============================================================================
684
- // Tree Rendering Utilities
685
- // =============================================================================
686
- /**
687
- * Render a list of items with tree branches, handling truncation.
688
- *
689
- * @param items - Full list of items to render
690
- * @param expanded - Whether view is expanded
691
- * @param maxCollapsed - Max items to show when collapsed
692
- * @param renderItem - Function to render a single item
693
- * @param itemType - Type name for "more X" message (e.g., "file", "entry")
694
- * @param theme - Theme instance
695
- * @returns Array of formatted lines
696
- */
697
- export function renderTreeList<T>(
698
- items: T[],
699
- expanded: boolean,
700
- maxCollapsed: number,
701
- renderItem: (item: T, branch: string, isLast: boolean, theme: Theme) => string,
702
- itemType: string,
703
- theme: Theme,
704
- ): string[] {
705
- const lines: string[] = [];
706
- const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed);
707
-
708
- for (let i = 0; i < maxItems; i++) {
709
- const isLast = i === maxItems - 1 && (expanded || items.length <= maxCollapsed);
710
- const branch = getTreeBranch(isLast, theme);
711
- lines.push(renderItem(items[i], branch, isLast, theme));
712
- }
713
-
714
- if (!expanded && items.length > maxCollapsed) {
715
- const remaining = items.length - maxCollapsed;
716
- lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType))}`);
717
- }
718
-
719
- return lines;
720
- }
package/src/tools/ssh.ts CHANGED
@@ -12,7 +12,8 @@ import sshDescriptionBase from "../prompts/tools/ssh.md" with { type: "text" };
12
12
  import type { SSHHostInfo } from "../ssh/connection-manager";
13
13
  import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
14
14
  import { executeSSH } from "../ssh/ssh-executor";
15
- import { renderOutputBlock, renderStatusLine } from "../tui";
15
+ import { renderStatusLine } from "../tui";
16
+ import { CachedOutputBlock } from "../tui/output-block";
16
17
  import type { ToolSession } from ".";
17
18
  import type { OutputMeta } from "./output-meta";
18
19
  import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
@@ -249,7 +250,6 @@ export const sshToolRenderer = {
249
250
  uiTheme: Theme,
250
251
  args?: SshRenderArgs,
251
252
  ): Component {
252
- const { expanded, renderContext } = options;
253
253
  const details = result.details;
254
254
  const host = args?.host || "…";
255
255
  const command = args?.command || "…";
@@ -257,59 +257,62 @@ export const sshToolRenderer = {
257
257
  { icon: "success", title: "SSH", description: `[${host}] $ ${command}` },
258
258
  uiTheme,
259
259
  );
260
- const outputLines: string[] = [];
261
-
262
260
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
263
- const output = textContent.trimEnd();
264
-
265
- if (output) {
266
- if (expanded) {
267
- outputLines.push(...output.split("\n").map(line => uiTheme.fg("toolOutput", line)));
268
- } else if (renderContext?.visualLines) {
269
- const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
270
- if (skippedCount > 0) {
271
- outputLines.push(
272
- uiTheme.fg(
273
- "dim",
274
- `… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
275
- ),
276
- );
277
- }
278
- const styledVisual = visualLines.map(line =>
279
- line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line),
280
- );
281
- outputLines.push(...styledVisual);
282
- } else {
283
- const outputLinesRaw = output.split("\n");
284
- const maxLines = 5;
285
- const displayLines = outputLinesRaw.slice(0, maxLines);
286
- const remaining = outputLinesRaw.length - maxLines;
287
- outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line)));
288
- if (remaining > 0) {
289
- outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`));
290
- }
291
- }
292
- }
293
-
294
261
  const truncation = details?.meta?.truncation;
295
- if (truncation) {
296
- const warnings: string[] = [];
297
- if (truncation.artifactId) {
298
- warnings.push(`Full output: artifact://${truncation.artifactId}`);
299
- }
300
- if (truncation.truncatedBy === "lines") {
301
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
302
- } else {
303
- warnings.push(
304
- `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`,
305
- );
306
- }
307
- outputLines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)));
308
- }
262
+ const outputBlock = new CachedOutputBlock();
309
263
 
310
264
  return {
311
- render: (width: number) =>
312
- renderOutputBlock(
265
+ render: (width: number): string[] => {
266
+ // REACTIVE: read mutable options at render time
267
+ const { expanded, renderContext } = options;
268
+ const output = textContent.trimEnd();
269
+ const outputLines: string[] = [];
270
+
271
+ if (output) {
272
+ if (expanded) {
273
+ outputLines.push(...output.split("\n").map(line => uiTheme.fg("toolOutput", line)));
274
+ } else if (renderContext?.visualLines) {
275
+ const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
276
+ if (skippedCount > 0) {
277
+ outputLines.push(
278
+ uiTheme.fg(
279
+ "dim",
280
+ `… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
281
+ ),
282
+ );
283
+ }
284
+ const styledVisual = visualLines.map(line =>
285
+ line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line),
286
+ );
287
+ outputLines.push(...styledVisual);
288
+ } else {
289
+ const outputLinesRaw = output.split("\n");
290
+ const maxLines = 5;
291
+ const displayLines = outputLinesRaw.slice(0, maxLines);
292
+ const remaining = outputLinesRaw.length - maxLines;
293
+ outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line)));
294
+ if (remaining > 0) {
295
+ outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`));
296
+ }
297
+ }
298
+ }
299
+
300
+ if (truncation) {
301
+ const warnings: string[] = [];
302
+ if (truncation.artifactId) {
303
+ warnings.push(`Full output: artifact://${truncation.artifactId}`);
304
+ }
305
+ if (truncation.truncatedBy === "lines") {
306
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
307
+ } else {
308
+ warnings.push(
309
+ `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`,
310
+ );
311
+ }
312
+ outputLines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)));
313
+ }
314
+
315
+ return outputBlock.render(
313
316
  {
314
317
  header,
315
318
  state: "success",
@@ -317,8 +320,11 @@ export const sshToolRenderer = {
317
320
  width,
318
321
  },
319
322
  uiTheme,
320
- ),
321
- invalidate: () => {},
323
+ );
324
+ },
325
+ invalidate: () => {
326
+ outputBlock.invalidate();
327
+ },
322
328
  };
323
329
  },
324
330
  mergeCallAndResult: true,
@@ -113,8 +113,8 @@ export class SubmitResultTool implements AgentTool<TObject, SubmitResultDetails>
113
113
 
114
114
  // Skip validation when aborting - data is optional for aborts
115
115
  if (status === "success") {
116
- if (params.data === undefined) {
117
- throw new Error("data is required when status is 'success'");
116
+ if (params.data === undefined || params.data === null) {
117
+ throw new Error("data is required when status is 'success' (got null/undefined)");
118
118
  }
119
119
  if (this.schemaError) {
120
120
  throw new Error(`Invalid output schema: ${this.schemaError}`);
@@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
13
13
  import type { ToolSession } from "../sdk";
14
- import { renderStatusLine, renderTreeList } from "../tui";
14
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
15
  import { PREVIEW_LIMITS } from "./render-utils";
16
16
 
17
17
  const todoWriteSchema = Type.Object({
@@ -227,7 +227,6 @@ export const todoWriteToolRenderer = {
227
227
  uiTheme: Theme,
228
228
  _args?: TodoWriteRenderArgs,
229
229
  ): Component {
230
- const { expanded } = options;
231
230
  const todos = result.details?.todos ?? [];
232
231
  const header = renderStatusLine(
233
232
  { icon: "success", title: "Todo Write", meta: [`${todos.length} items`] },
@@ -235,20 +234,39 @@ export const todoWriteToolRenderer = {
235
234
  );
236
235
  if (todos.length === 0) {
237
236
  const fallback = result.content?.find(c => c.type === "text")?.text ?? "No todos";
238
- return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0);
237
+ const renderedLines = [header, uiTheme.fg("dim", fallback)];
238
+ return {
239
+ render() {
240
+ return renderedLines;
241
+ },
242
+ invalidate() {},
243
+ };
239
244
  }
240
- const lines = renderTreeList(
241
- {
242
- items: todos,
243
- expanded,
244
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
245
- itemType: "todo",
246
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
247
- },
248
- uiTheme,
249
- );
245
+ let cached: RenderCache | undefined;
250
246
 
251
- return new Text([header, ...lines].join("\n"), 0, 0);
247
+ return {
248
+ render(width) {
249
+ const { expanded } = options;
250
+ const key = new Hasher().bool(expanded).u32(width).digest();
251
+ if (cached?.key === key) return cached.lines;
252
+ const treeLines = renderTreeList(
253
+ {
254
+ items: todos,
255
+ expanded,
256
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
257
+ itemType: "todo",
258
+ renderItem: todo => formatTodoLine(todo, uiTheme, ""),
259
+ },
260
+ uiTheme,
261
+ );
262
+ const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
263
+ cached = { key, lines };
264
+ return lines;
265
+ },
266
+ invalidate() {
267
+ cached = undefined;
268
+ },
269
+ };
252
270
  },
253
271
  mergeCallAndResult: true,
254
272
  };
@@ -287,7 +287,7 @@ export function truncateLine(
287
287
  if (line.length <= maxChars) {
288
288
  return { text: line, wasTruncated: false };
289
289
  }
290
- return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
290
+ return { text: `${line.slice(0, maxChars)}…`, wasTruncated: true };
291
291
  }
292
292
 
293
293
  // =============================================================================
@@ -8,14 +8,14 @@ import type {
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import { untilAborted } from "@oh-my-pi/pi-utils";
11
- import { Type } from "@sinclair/typebox";
11
+ import { type Static, Type } from "@sinclair/typebox";
12
12
  import { renderPromptTemplate } from "../config/prompt-templates";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
15
15
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
16
16
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
17
17
  import type { ToolSession } from "../sdk";
18
- import { renderStatusLine } from "../tui";
18
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
19
19
  import { type OutputMeta, outputMeta } from "./output-meta";
20
20
  import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
21
21
  import {
@@ -23,6 +23,7 @@ import {
23
23
  formatExpandHint,
24
24
  formatMoreItems,
25
25
  formatStatusIcon,
26
+ replaceTabs,
26
27
  shortenPath,
27
28
  ToolUIKit,
28
29
  } from "./render-utils";
@@ -33,6 +34,8 @@ const writeSchema = Type.Object({
33
34
  content: Type.String({ description: "Content to write to the file" }),
34
35
  });
35
36
 
37
+ export type WriteToolInput = Static<typeof writeSchema>;
38
+
36
39
  /** Details returned by the write tool for TUI rendering */
37
40
  export interface WriteToolDetails {
38
41
  diagnostics?: FileDiagnosticsResult;
@@ -158,7 +161,7 @@ function formatStreamingContent(content: string, uiTheme: Theme, ui: ToolUIKit):
158
161
  text += uiTheme.fg("dim", `… (${hidden} earlier lines)\n`);
159
162
  }
160
163
  for (const line of displayLines) {
161
- text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`;
164
+ text += `${uiTheme.fg("toolOutput", ui.truncate(replaceTabs(line), 80))}\n`;
162
165
  }
163
166
  text += uiTheme.fg("dim", `… (streaming)`);
164
167
  return text;
@@ -173,7 +176,7 @@ function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme
173
176
 
174
177
  let text = "\n\n";
175
178
  for (const line of displayLines) {
176
- text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`;
179
+ text += `${uiTheme.fg("toolOutput", ui.truncate(replaceTabs(line), 80))}\n`;
177
180
  }
178
181
  if (!expanded && hidden > 0) {
179
182
  const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
@@ -208,7 +211,7 @@ export const writeToolRenderer = {
208
211
 
209
212
  renderResult(
210
213
  result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
211
- { expanded }: RenderResultOptions,
214
+ options: RenderResultOptions,
212
215
  uiTheme: Theme,
213
216
  args?: WriteRenderArgs,
214
217
  ): Component {
@@ -230,29 +233,42 @@ export const writeToolRenderer = {
230
233
  },
231
234
  uiTheme,
232
235
  );
233
- let text = header;
234
-
235
- // Add metadata line
236
- text += `\n${formatMetadataLine(lineCount, lang ?? "text", uiTheme)}`;
237
-
238
- // Show content preview (collapsed tail, expandable)
239
- text += renderContentPreview(fileContent, expanded, uiTheme, ui);
240
-
241
- // Show diagnostics if available
242
- if (result.details?.diagnostics) {
243
- const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, fp =>
244
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
245
- );
246
- if (diagText.trim()) {
247
- const diagLines = diagText.split("\n");
248
- const firstNonEmpty = diagLines.findIndex(line => line.trim());
249
- if (firstNonEmpty >= 0) {
250
- text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
236
+ const metadataLine = formatMetadataLine(lineCount, lang ?? "text", uiTheme);
237
+ const diagnostics = result.details?.diagnostics;
238
+
239
+ let cached: RenderCache | undefined;
240
+
241
+ return {
242
+ render(width: number) {
243
+ const { expanded } = options;
244
+ const key = new Hasher().bool(expanded).u32(width).digest();
245
+ if (cached?.key === key) return cached.lines;
246
+
247
+ let text = header;
248
+ text += `\n${metadataLine}`;
249
+ text += renderContentPreview(fileContent, expanded, uiTheme, ui);
250
+
251
+ if (diagnostics) {
252
+ const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
253
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
254
+ );
255
+ if (diagText.trim()) {
256
+ const diagLines = diagText.split("\n");
257
+ const firstNonEmpty = diagLines.findIndex(line => line.trim());
258
+ if (firstNonEmpty >= 0) {
259
+ text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
260
+ }
261
+ }
251
262
  }
252
- }
253
- }
254
263
 
255
- return new Text(text, 0, 0);
264
+ const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit));
265
+ cached = { key, lines };
266
+ return lines;
267
+ },
268
+ invalidate() {
269
+ cached = undefined;
270
+ },
271
+ };
256
272
  },
257
273
  mergeCallAndResult: true,
258
274
  };
@@ -90,7 +90,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
90
90
  const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, outputMaxLines);
91
91
  const displayLines = rawLines
92
92
  .slice(0, maxLines)
93
- .map(line => (line.includes("\x1b[") ? line : theme.fg("toolOutput", line)));
93
+ .map(line => (line.includes("\x1b[") ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))));
94
94
  outputLines.push(...displayLines);
95
95
  const remaining = rawLines.length - maxLines;
96
96
  if (remaining > 0) {
@@ -4,7 +4,8 @@
4
4
  import { padding, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme } from "../modes/theme/theme";
6
6
  import type { State } from "./types";
7
- import { getStateBgColor, padToWidth, truncateToWidth } from "./utils";
7
+ import type { RenderCache } from "./utils";
8
+ import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
8
9
 
9
10
  export interface OutputBlockOptions {
10
11
  header?: string;
@@ -31,7 +32,18 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
31
32
  ? "accent"
32
33
  : "dim";
33
34
  const border = (text: string) => theme.fg(borderColor, text);
34
- const bgFn = state && applyBg ? (text: string) => theme.bg(getStateBgColor(state), text) : undefined;
35
+ const bgFn = (() => {
36
+ if (!state || !applyBg) return undefined;
37
+ const bgAnsi = theme.getBgAnsi(getStateBgColor(state));
38
+ // Keep block background stable even if inner content contains SGR resets (e.g. "\x1b[0m"),
39
+ // which would otherwise clear the outer background mid-line.
40
+ return (text: string) => {
41
+ const stabilized = text
42
+ .replace(/\x1b\[(?:0)?m/g, m => `${m}${bgAnsi}`)
43
+ .replace(/\x1b\[49m/g, m => `${m}${bgAnsi}`);
44
+ return `${bgAnsi}${stabilized}\x1b[49m`;
45
+ };
46
+ })();
35
47
 
36
48
  const buildBarLine = (leftChar: string, rightChar: string, label?: string, meta?: string): string => {
37
49
  const left = border(`${leftChar}${cap}`);
@@ -69,7 +81,10 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
69
81
  }
70
82
  const allLines = section.lines.flatMap(l => l.split("\n"));
71
83
  for (const line of allLines) {
72
- const text = truncateToWidth(line, contentWidth);
84
+ // Sections may receive content that was already padded to terminal width
85
+ // (e.g. from Text.render()). Trailing spaces would trigger truncateToWidth()
86
+ // to append an ellipsis even when the *semantic* content fits.
87
+ const text = truncateToWidth(line.trimEnd(), contentWidth);
73
88
  const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(text)));
74
89
  const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`;
75
90
  lines.push(padToWidth(fullLine, lineWidth, bgFn));
@@ -84,3 +99,46 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
84
99
 
85
100
  return lines;
86
101
  }
102
+
103
+ /**
104
+ * Cached wrapper around `renderOutputBlock`.
105
+ *
106
+ * Since output blocks are re-rendered on every frame (via `render(width)` closures),
107
+ * but their content rarely changes, this cache avoids redundant `visibleWidth()` and
108
+ * `padding()` computations on ~99% of render calls.
109
+ */
110
+ export class CachedOutputBlock {
111
+ private cache?: RenderCache;
112
+
113
+ /** Render with caching. Returns cached result if options haven't changed. */
114
+ render(options: OutputBlockOptions, theme: Theme): string[] {
115
+ const key = this.buildKey(options);
116
+ if (this.cache?.key === key) return this.cache.lines;
117
+ const lines = renderOutputBlock(options, theme);
118
+ this.cache = { key, lines };
119
+ return lines;
120
+ }
121
+
122
+ /** Invalidate the cache, forcing a rebuild on next render. */
123
+ invalidate(): void {
124
+ this.cache = undefined;
125
+ }
126
+
127
+ private buildKey(options: OutputBlockOptions): bigint {
128
+ const h = new Hasher();
129
+ h.u32(options.width);
130
+ h.optional(options.header);
131
+ h.optional(options.headerMeta);
132
+ h.optional(options.state);
133
+ h.bool(options.applyBg ?? true);
134
+ if (options.sections) {
135
+ for (const s of options.sections) {
136
+ h.optional(s.label);
137
+ for (const line of s.lines) {
138
+ h.str(line);
139
+ }
140
+ }
141
+ }
142
+ return h.digest();
143
+ }
144
+ }
@@ -2,7 +2,7 @@
2
2
  * Hierarchical tree list rendering helper.
3
3
  */
4
4
  import type { Theme } from "../modes/theme/theme";
5
- import { formatMoreItems } from "../tools/render-utils";
5
+ import { formatMoreItems, replaceTabs } from "../tools/render-utils";
6
6
  import type { TreeContext } from "./types";
7
7
  import { getTreeBranch, getTreeContinuePrefix } from "./utils";
8
8
 
@@ -35,12 +35,12 @@ export function renderTreeList<T>(options: TreeListOptions<T>, theme: Theme): st
35
35
  const rendered = renderItem(items[i], context);
36
36
  if (Array.isArray(rendered)) {
37
37
  if (rendered.length === 0) continue;
38
- lines.push(`${prefix}${rendered[0]}`);
38
+ lines.push(`${prefix}${replaceTabs(rendered[0])}`);
39
39
  for (let j = 1; j < rendered.length; j++) {
40
- lines.push(`${continuePrefix}${rendered[j]}`);
40
+ lines.push(`${continuePrefix}${replaceTabs(rendered[j])}`);
41
41
  }
42
42
  } else {
43
- lines.push(`${prefix}${rendered}`);
43
+ lines.push(`${prefix}${replaceTabs(rendered)}`);
44
44
  }
45
45
  }
46
46
 
package/src/tui/utils.ts CHANGED
@@ -5,7 +5,77 @@ import { padding, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme, ThemeBg } from "../modes/theme/theme";
6
6
  import type { IconType, State } from "./types";
7
7
 
8
- export { truncateToWidth } from "@oh-my-pi/pi-tui";
8
+ export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui";
9
+
10
+ /** Cached typed-array scratch space for hashing non-string primitives. */
11
+ const hashBuf = new ArrayBuffer(8);
12
+ const hashView = new DataView(hashBuf);
13
+ const hashBytes1 = new Uint8Array(hashBuf, 0, 1);
14
+ const hashBytes4 = new Uint8Array(hashBuf, 0, 4);
15
+ const hashBytes8 = new Uint8Array(hashBuf, 0, 8);
16
+
17
+ /**
18
+ * Incremental xxHash64 key builder.
19
+ *
20
+ * Chains `Bun.hash.xxHash64` calls via seeding — each fed value
21
+ * mixes into the running hash without intermediate string allocations.
22
+ * Accepts strings, numbers (u32), booleans, bigints, and `undefined`/`null`
23
+ * (hashed as a sentinel byte) natively.
24
+ */
25
+ export class Hasher {
26
+ private h = 0n;
27
+
28
+ /** Feed a string. */
29
+ str(s: string): this {
30
+ hashView.setUint32(0, s.length);
31
+ this.h = Bun.hash.xxHash64(hashBytes4, this.h);
32
+ this.h = Bun.hash.xxHash64(s, this.h);
33
+ return this;
34
+ }
35
+
36
+ /** Feed an unsigned 32-bit integer. */
37
+ u32(n: number): this {
38
+ hashView.setUint32(0, n);
39
+ this.h = Bun.hash.xxHash64(hashBytes4, this.h);
40
+ return this;
41
+ }
42
+
43
+ /** Feed a 64-bit bigint. */
44
+ u64(n: bigint): this {
45
+ hashView.setBigUint64(0, n);
46
+ this.h = Bun.hash.xxHash64(hashBytes8, this.h);
47
+ return this;
48
+ }
49
+
50
+ /** Feed a boolean (single byte: 1 = true, 0 = false). */
51
+ bool(b: boolean): this {
52
+ hashView.setUint8(0, b ? 1 : 0);
53
+ this.h = Bun.hash.xxHash64(hashBytes1, this.h);
54
+ return this;
55
+ }
56
+
57
+ /** Feed a value that may be `undefined` or `null` (hashed as a 0xFF sentinel byte). */
58
+ optional(v: string | undefined | null): this {
59
+ if (v == null) {
60
+ hashView.setUint8(0, 0xff);
61
+ this.h = Bun.hash.xxHash64(hashBytes1, this.h);
62
+ } else {
63
+ this.h = Bun.hash.xxHash64(v, this.h);
64
+ }
65
+ return this;
66
+ }
67
+
68
+ /** Return the final hash digest. */
69
+ digest(): bigint {
70
+ return this.h;
71
+ }
72
+ }
73
+
74
+ /** Render-cache entry used by tool renderers. */
75
+ export interface RenderCache {
76
+ key: bigint;
77
+ lines: string[];
78
+ }
9
79
 
10
80
  export function buildTreePrefix(ancestors: boolean[], theme: Theme): string {
11
81
  return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join("");
@@ -10,7 +10,7 @@ function toError(value: unknown): Error {
10
10
  }
11
11
 
12
12
  function truncate(content: string, maxLength: number): string {
13
- return content.length > maxLength ? `${content.slice(0, maxLength)}...` : content;
13
+ return content.length > maxLength ? `${content.slice(0, maxLength)}…` : content;
14
14
  }
15
15
 
16
16
  export class FrontmatterError extends Error {
@@ -84,7 +84,7 @@ export async function generateSessionTitle(
84
84
 
85
85
  // Truncate message if too long
86
86
  const truncatedMessage =
87
- firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
87
+ firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
88
88
  const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
89
89
 
90
90
  for (const model of candidates) {