@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.
- package/CHANGELOG.md +119 -4
- package/examples/extensions/plan-mode.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/hooks/status-line.ts +1 -1
- package/examples/sdk/11-sessions.ts +1 -1
- package/package.json +8 -8
- package/src/cli/args.ts +9 -6
- package/src/cli/update-cli.ts +2 -2
- package/src/commands/index/index.ts +2 -5
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/changelog/index.ts +2 -2
- package/src/config/keybindings.ts +16 -1
- package/src/config/model-registry.ts +25 -20
- package/src/config/model-resolver.ts +8 -8
- package/src/config/resolve-config-value.ts +92 -0
- package/src/config/settings-schema.ts +9 -0
- package/src/config.ts +14 -1
- package/src/export/html/template.css +7 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +33 -16
- package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
- package/src/extensibility/extensions/index.ts +18 -0
- package/src/extensibility/extensions/loader.ts +15 -0
- package/src/extensibility/extensions/runner.ts +78 -1
- package/src/extensibility/extensions/types.ts +131 -5
- package/src/extensibility/extensions/wrapper.ts +1 -1
- package/src/extensibility/plugins/git-url.ts +270 -0
- package/src/extensibility/plugins/index.ts +2 -0
- package/src/extensibility/slash-commands.ts +45 -0
- package/src/index.ts +7 -0
- package/src/lsp/render.ts +50 -43
- package/src/lsp/utils.ts +2 -2
- package/src/main.ts +11 -10
- package/src/mcp/transports/stdio.ts +3 -5
- package/src/modes/components/custom-message.ts +0 -8
- package/src/modes/components/diff.ts +41 -13
- package/src/modes/components/footer.ts +4 -4
- package/src/modes/components/model-selector.ts +4 -0
- package/src/modes/components/todo-display.ts +13 -3
- package/src/modes/components/tool-execution.ts +30 -16
- package/src/modes/components/tree-selector.ts +50 -19
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +34 -2
- package/src/modes/controllers/input-controller.ts +47 -33
- package/src/modes/controllers/selector-controller.ts +10 -15
- package/src/modes/interactive-mode.ts +50 -38
- package/src/modes/print-mode.ts +6 -0
- package/src/modes/rpc/rpc-client.ts +4 -4
- package/src/modes/rpc/rpc-mode.ts +17 -2
- package/src/modes/rpc/rpc-types.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +3 -1
- package/src/patch/applicator.ts +106 -4
- package/src/patch/fuzzy.ts +1 -1
- package/src/patch/shared.ts +77 -63
- package/src/prompts/system/plan-mode-active.md +6 -6
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/ask.md +2 -2
- package/src/prompts/tools/gemini-image.md +2 -2
- package/src/prompts/tools/lsp.md +2 -2
- package/src/prompts/tools/patch.md +1 -1
- package/src/prompts/tools/python.md +3 -3
- package/src/prompts/tools/task.md +7 -1
- package/src/prompts/tools/todo-write.md +2 -2
- package/src/prompts/tools/web-search.md +2 -2
- package/src/prompts/tools/write.md +2 -5
- package/src/sdk.ts +15 -11
- package/src/session/agent-session.ts +92 -34
- package/src/session/auth-storage.ts +2 -1
- package/src/session/blob-store.ts +105 -0
- package/src/session/session-manager.ts +107 -44
- package/src/task/executor.ts +19 -9
- package/src/task/render.ts +80 -58
- package/src/tools/ask.ts +28 -5
- package/src/tools/bash.ts +47 -39
- package/src/tools/browser.ts +248 -26
- package/src/tools/calculator.ts +42 -23
- package/src/tools/fetch.ts +33 -16
- package/src/tools/find.ts +57 -22
- package/src/tools/grep.ts +54 -25
- package/src/tools/index.ts +5 -5
- package/src/tools/notebook.ts +19 -6
- package/src/tools/path-utils.ts +26 -1
- package/src/tools/python.ts +20 -14
- package/src/tools/read.ts +21 -8
- package/src/tools/render-utils.ts +5 -45
- package/src/tools/ssh.ts +59 -53
- package/src/tools/submit-result.ts +2 -2
- package/src/tools/todo-write.ts +32 -14
- package/src/tools/truncate.ts +1 -1
- package/src/tools/write.ts +42 -26
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +61 -3
- package/src/tui/tree-list.ts +4 -4
- package/src/tui/utils.ts +71 -1
- package/src/utils/frontmatter.ts +1 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/utils/tools-manager.ts +18 -2
- package/src/web/scrapers/osv.ts +4 -1
- package/src/web/scrapers/youtube.ts +1 -1
- package/src/web/search/index.ts +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -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
|
-
|
|
237
|
+
const renderedLines = [header, uiTheme.fg("dim", fallback)];
|
|
238
|
+
return {
|
|
239
|
+
render() {
|
|
240
|
+
return renderedLines;
|
|
241
|
+
},
|
|
242
|
+
invalidate() {},
|
|
243
|
+
};
|
|
239
244
|
}
|
|
240
|
-
|
|
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
|
|
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
|
};
|
package/src/tools/truncate.ts
CHANGED
|
@@ -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)}
|
|
290
|
+
return { text: `${line.slice(0, maxChars)}…`, wasTruncated: true };
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
// =============================================================================
|
package/src/tools/write.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/tui/code-cell.ts
CHANGED
|
@@ -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) {
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tui/tree-list.ts
CHANGED
|
@@ -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("");
|
package/src/utils/frontmatter.ts
CHANGED
|
@@ -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)}
|
|
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)}
|
|
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) {
|