@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  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 +1 -7
  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 +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
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 {
@@ -33,6 +33,8 @@ const writeSchema = Type.Object({
33
33
  content: Type.String({ description: "Content to write to the file" }),
34
34
  });
35
35
 
36
+ export type WriteToolInput = Static<typeof writeSchema>;
37
+
36
38
  /** Details returned by the write tool for TUI rendering */
37
39
  export interface WriteToolDetails {
38
40
  diagnostics?: FileDiagnosticsResult;
@@ -208,7 +210,7 @@ export const writeToolRenderer = {
208
210
 
209
211
  renderResult(
210
212
  result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
211
- { expanded }: RenderResultOptions,
213
+ options: RenderResultOptions,
212
214
  uiTheme: Theme,
213
215
  args?: WriteRenderArgs,
214
216
  ): Component {
@@ -230,29 +232,42 @@ export const writeToolRenderer = {
230
232
  },
231
233
  uiTheme,
232
234
  );
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")}`;
235
+ const metadataLine = formatMetadataLine(lineCount, lang ?? "text", uiTheme);
236
+ const diagnostics = result.details?.diagnostics;
237
+
238
+ let cached: RenderCache | undefined;
239
+
240
+ return {
241
+ render(width: number) {
242
+ const { expanded } = options;
243
+ const key = new Hasher().bool(expanded).u32(width).digest();
244
+ if (cached?.key === key) return cached.lines;
245
+
246
+ let text = header;
247
+ text += `\n${metadataLine}`;
248
+ text += renderContentPreview(fileContent, expanded, uiTheme, ui);
249
+
250
+ if (diagnostics) {
251
+ const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
252
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
253
+ );
254
+ if (diagText.trim()) {
255
+ const diagLines = diagText.split("\n");
256
+ const firstNonEmpty = diagLines.findIndex(line => line.trim());
257
+ if (firstNonEmpty >= 0) {
258
+ text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
259
+ }
260
+ }
251
261
  }
252
- }
253
- }
254
262
 
255
- return new Text(text, 0, 0);
263
+ const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit));
264
+ cached = { key, lines };
265
+ return lines;
266
+ },
267
+ invalidate() {
268
+ cached = undefined;
269
+ },
270
+ };
256
271
  },
257
272
  mergeCallAndResult: true,
258
273
  };
@@ -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) {
@@ -269,6 +269,12 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<
269
269
  return false;
270
270
  }
271
271
 
272
+ // Termux package names for tools
273
+ const TERMUX_PACKAGES: Partial<Record<ToolName, string>> = {
274
+ sd: "sd",
275
+ sg: "ast-grep",
276
+ };
277
+
272
278
  // Ensure a tool is available, downloading if necessary
273
279
  // Returns the path to the tool, or null if unavailable
274
280
  type EnsureToolOptions = {
@@ -284,13 +290,23 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
284
290
  return existingPath;
285
291
  }
286
292
 
293
+ // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
294
+ // Users must install via pkg.
295
+ if (os.platform() === "android") {
296
+ const pkgName = TERMUX_PACKAGES[tool] ?? tool;
297
+ if (!silent) {
298
+ logger.warn(`${TOOLS[tool]?.name ?? tool} not found. Install with: pkg install ${pkgName}`);
299
+ }
300
+ return undefined;
301
+ }
302
+
287
303
  // Handle Python tools
288
304
  const pythonConfig = PYTHON_TOOLS[tool];
289
305
  if (pythonConfig) {
290
306
  if (!silent) {
291
307
  logger.debug(`${pythonConfig.name} not found. Installing via uv/pip...`);
292
308
  }
293
- notify?.(`Installing ${pythonConfig.name}...`);
309
+ notify?.(`Installing ${pythonConfig.name}…`);
294
310
  const success = await installPythonPackage(pythonConfig.package, signal);
295
311
  if (success) {
296
312
  // Re-check for the command after installation
@@ -315,7 +331,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
315
331
  if (!silent) {
316
332
  logger.debug(`${config.name} not found. Downloading...`);
317
333
  }
318
- notify?.(`Downloading ${config.name}...`);
334
+ notify?.(`Downloading ${config.name}…`);
319
335
 
320
336
  try {
321
337
  const path = await downloadTool(tool, signal);
@@ -145,8 +145,11 @@ export const handleOsv: SpecialHandler = async (
145
145
  if (affected.versions?.length) {
146
146
  const versions =
147
147
  affected.versions.length > 10
148
- ? `${affected.versions.slice(0, 10).join(", ")}... (${affected.versions.length} total)`
148
+ ? `${affected.versions.slice(0, 10).join(", ")} (${affected.versions.length} total)`
149
149
  : affected.versions.join(", ");
150
+ affected.versions.length > 10
151
+ ? `${affected.versions.slice(0, 10).join(", ")}… (${affected.versions.length} total)`
152
+ : affected.versions.join(", ");
150
153
  md += `- **Versions:** ${versions}\n`;
151
154
  }
152
155
 
@@ -287,7 +287,7 @@ export const handleYouTube: SpecialHandler = async (
287
287
 
288
288
  if (description) {
289
289
  // Truncate long descriptions
290
- const descPreview = description.length > 1000 ? `${description.slice(0, 1000)}...` : description;
290
+ const descPreview = description.length > 1000 ? `${description.slice(0, 1000)}…` : description;
291
291
  md += `---\n\n## Description\n\n${descPreview}\n\n`;
292
292
  }
293
293
 
@@ -79,7 +79,7 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
79
79
  /** Truncate text for tool output */
80
80
  function truncateText(text: string, maxLen: number): string {
81
81
  if (text.length <= maxLen) return text;
82
- return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
82
+ return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
83
83
  }
84
84
 
85
85
  function formatCount(label: string, count: number): string {