diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7b20003a1..40fd1b47d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Exported `ModeChangeEntry` type for tracking agent mode transitions @@ -34,6 +33,8 @@ ### Changed +- Improved text truncation across tool renderers to respect terminal width constraints and prevent output overflow +- Enhanced render caching to include width parameter for accurate cache invalidation when terminal width changes - HTML export filter now treats `mode_change` entries as settings entries alongside model changes and thinking level changes - Replaced ellipsis string (`...`) with Unicode ellipsis character (`…`) throughout UI text and truncation logic for improved typography - Improved render performance by introducing caching for tool output blocks and search results to avoid redundant text width and padding computations diff --git a/packages/coding-agent/src/modes/components/todo-display.ts b/packages/coding-agent/src/modes/components/todo-display.ts index e37893f26..281b40563 100644 --- a/packages/coding-agent/src/modes/components/todo-display.ts +++ b/packages/coding-agent/src/modes/components/todo-display.ts @@ -1,8 +1,9 @@ import * as path from "node:path"; -import { Text } from "@oh-my-pi/pi-tui"; +import { Ellipsis, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; import { logger } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import type { TodoItem } from "../../modes/types"; +import { Hasher, type RenderCache } from "../../tui"; const TODO_FILE_NAME = "todos.json"; @@ -29,6 +30,7 @@ export class TodoDisplayComponent { public todos: TodoItem[] = []; private expanded = false; private visible = false; + private cached: RenderCache | undefined; constructor(private readonly sessionFile: string | null) {} @@ -44,26 +46,32 @@ export class TodoDisplayComponent { const data = await loadTodoFile(todoPath); this.todos = data?.todos ?? []; this.visible = this.todos.length > 0; + this.cached = undefined; } setTodos(todos: TodoItem[]): void { this.todos = todos; this.visible = this.todos.length > 0; + this.cached = undefined; } setExpanded(expanded: boolean): void { this.expanded = expanded; + this.cached = undefined; } isVisible(): boolean { return this.visible; } - render(_width: number): string[] { + render(width: number): string[] { if (!this.visible || this.todos.length === 0) { return []; } + const key = new Hasher().bool(this.expanded).u32(width).digest(); + if (this.cached?.key === key) return this.cached.lines; + const lines: string[] = []; const maxItems = this.expanded ? this.todos.length : Math.min(5, this.todos.length); const hasMore = !this.expanded && this.todos.length > 5; @@ -93,7 +101,9 @@ export class TodoDisplayComponent { lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`)); } - return lines; + const result = lines.map(l => truncateToWidth(l, width, Ellipsis.Omit)); + this.cached = { key, lines: result }; + return result; } getRenderedComponent(): Text | null { diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 50a1258c8..f2a14d185 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -19,7 +19,7 @@ import { truncateDiffByHunk, } from "../tools/render-utils"; import type { RenderCallOptions } from "../tools/renderers"; -import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import type { DiffError, DiffResult, Operation } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -283,7 +283,8 @@ export const editToolRenderer = { ); } - const lines = width > 0 ? text.split("\n").map(line => truncateToWidth(line, width)) : text.split("\n"); + const lines = + width > 0 ? text.split("\n").map(line => truncateToWidth(line, width, Ellipsis.Omit)) : text.split("\n"); cached = { key, lines }; return lines; }, diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 163a2e707..72f8d3e74 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -24,7 +24,7 @@ import { type ReportFindingDetails, type SubmitReviewDetails, } from "../tools/review"; -import { Hasher, type RenderCache, renderStatusLine } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types"; @@ -906,7 +906,7 @@ export function renderResult( if (lines.length === 0) { const text = fallbackText.trim() ? fallbackText : "No results"; - const result = [theme.fg("dim", truncateToWidth(text, 140))]; + const result = [theme.fg("dim", truncateToWidth(text, width))]; cached = { key, lines: result }; return result; } @@ -925,7 +925,9 @@ export function renderResult( } } - const indented = lines.map(line => (line.length > 0 ? ` ${line}` : "")); + const indented = lines.map(line => + line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "", + ); cached = { key, lines: indented }; return indented; }, diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 029b966d0..6d0679ead 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -7,16 +7,9 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" }; -import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; -import { - formatCount, - formatEmptyMessage, - formatErrorMessage, - PREVIEW_LIMITS, - TRUNCATE_LENGTHS, - truncateToWidth, -} from "./render-utils"; +import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS } from "./render-utils"; // ============================================================================= // Token Types @@ -517,9 +510,9 @@ export const calculatorToolRenderer = { let cached: RenderCache | undefined; return { - render(_width) { + render(width) { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const treeLines = renderTreeList( { @@ -531,7 +524,7 @@ export const calculatorToolRenderer = { }, uiTheme, ); - const lines = [header, ...treeLines]; + const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines }; return lines; }, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index c1b0717c6..bad1ae74a 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -11,7 +11,15 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import findDescription from "../prompts/tools/find.md" with { type: "text" }; -import { type RenderCache, renderFileList, renderStatusLine, renderTreeList } from "../tui"; +import { + Ellipsis, + Hasher, + type RenderCache, + renderFileList, + renderStatusLine, + renderTreeList, + truncateToWidth, +} from "../tui"; import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import type { OutputMeta } from "./output-meta"; @@ -448,9 +456,9 @@ export const findToolRenderer = { ); let cached: RenderCache | undefined; return { - render(_width: number): string[] { + render(width: number): string[] { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const listLines = renderTreeList( { @@ -462,7 +470,7 @@ export const findToolRenderer = { }, uiTheme, ); - const result = [header, ...listLines]; + const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines: result }; return result; }, @@ -507,9 +515,9 @@ export const findToolRenderer = { let cached: RenderCache | undefined; return { - render(_width: number): string[] { + render(width: number): string[] { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const fileLines = renderFileList( { @@ -519,7 +527,7 @@ export const findToolRenderer = { }, uiTheme, ); - const result = [header, ...fileLines, ...extraLines]; + const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines: result }; return result; }, diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index a2a34f782..ee31cd406 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -10,7 +10,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; -import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; import { resolveToCwd } from "./path-utils"; @@ -350,9 +350,9 @@ export const grepToolRenderer = { ); let cached: RenderCache | undefined; return { - render(_width: number): string[] { + render(width: number): string[] { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const listLines = renderTreeList( { @@ -364,7 +364,7 @@ export const grepToolRenderer = { }, uiTheme, ); - const result = [header, ...listLines]; + const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines: result }; return result; }, @@ -449,9 +449,9 @@ export const grepToolRenderer = { let cached: RenderCache | undefined; return { - render(_width: number): string[] { + render(width: number): string[] { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const maxCollapsed = expanded ? matchGroups.length @@ -466,7 +466,7 @@ export const grepToolRenderer = { }, uiTheme, ); - const result = [header, ...matchLines, ...extraLines]; + const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines: result }; return result; }, diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index 95dfab75a..b122baf1d 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { PREVIEW_LIMITS } from "./render-utils"; const todoWriteSchema = Type.Object({ @@ -245,9 +245,9 @@ export const todoWriteToolRenderer = { let cached: RenderCache | undefined; return { - render(_width) { + render(width) { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const treeLines = renderTreeList( { @@ -259,7 +259,7 @@ export const todoWriteToolRenderer = { }, uiTheme, ); - const lines = [header, ...treeLines]; + const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines }; return lines; }, diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 13068c29b..19d338eb3 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -15,7 +15,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCal import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { type RenderCache, renderStatusLine } from "../tui"; +import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import { type OutputMeta, outputMeta } from "./output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard"; import { @@ -238,9 +238,9 @@ export const writeToolRenderer = { let cached: RenderCache | undefined; return { - render(_width: number) { + render(width: number) { const { expanded } = options; - const key = expanded ? 1n : 0n; + const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; let text = header; @@ -260,7 +260,7 @@ export const writeToolRenderer = { } } - const lines = text.split("\n"); + const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines }; return lines; }, diff --git a/packages/coding-agent/src/tui/utils.ts b/packages/coding-agent/src/tui/utils.ts index 6429b4faa..4b513895c 100644 --- a/packages/coding-agent/src/tui/utils.ts +++ b/packages/coding-agent/src/tui/utils.ts @@ -5,7 +5,7 @@ import { padding, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme, ThemeBg } from "../modes/theme/theme"; import type { IconType, State } from "./types"; -export { truncateToWidth } from "@oh-my-pi/pi-tui"; +export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; /** Cached typed-array scratch space for hashing non-string primitives. */ const hashBuf = new ArrayBuffer(8); diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 4ed5dafb2..40af24b1f 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -79,7 +79,7 @@ export class Box implements Component { for (const child of this.children) { const lines = child.render(contentWidth); for (const line of lines) { - childLines.push(leftPad + truncateToWidth(line, contentWidth)); + childLines.push(leftPad + (visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line)); } }