diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca0381b6f..8cff984f1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed stopping a codemode script or reaching its deadline waiting for a non-interruptible built-in to return on Bun ([#10748](https://github.com/earendil-works/pi/issues/10748)) - Fixed repeated codemode timeouts or aborts accumulating surviving workers; admission now limits live workers to two across sessions, counting cancelled workers until actual exit ([#10748](https://github.com/earendil-works/pi/issues/10748)) - Fixed `--model` choosing a provider by catalog order when several providers offer the match; Pi now prefers the provider with credentials and otherwise reports the ambiguity. `--models` and `enabledModels` add a model for each provider that offers it and warn about the ambiguity ([#10160](https://github.com/earendil-works/pi/issues/10160)) +- Fixed `read` line-range labels for numeric-string or null arguments in the TUI, session tree, HTML exports, and examples ([#9887](https://github.com/earendil-works/pi/issues/9887)) ## [1.2.0] - 2026-10-12 diff --git a/packages/coding-agent/examples/extensions/minimal-mode.ts b/packages/coding-agent/examples/extensions/minimal-mode.ts index 023fc6c8c..8bb55b212 100644 --- a/packages/coding-agent/examples/extensions/minimal-mode.ts +++ b/packages/coding-agent/examples/extensions/minimal-mode.ts @@ -25,6 +25,7 @@ import { createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, + formatReadLineRange, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { homedir } from "os"; @@ -65,11 +66,9 @@ export default function (pi: ExtensionAPI) { const path = shortenPath(args.path || ""); let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); - // Show line range if specified - if (args.offset !== undefined || args.limit !== undefined) { - const startLine = args.offset ?? 1; - const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; - pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + const lineRange = formatReadLineRange(args.offset, args.limit); + if (lineRange) { + pathDisplay += theme.fg("warning", lineRange); } return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0); diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index 71b1a33dc..843297454 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -22,6 +22,7 @@ import { StringEnum } from "@earendil-works/pi-ai"; import { CONFIG_DIR_NAME, type ExtensionAPI, + formatReadLineRange, getAgentDir, getMarkdownTheme, withFileMutationQueue, @@ -87,13 +88,10 @@ function formatToolCall( case "read": { const rawPath = (args.file_path || args.path || "...") as string; const filePath = shortenPath(rawPath); - const offset = args.offset as number | undefined; - const limit = args.limit as number | undefined; + const lineRange = formatReadLineRange(args.offset, args.limit); let text = themeFg("accent", filePath); - if (offset !== undefined || limit !== undefined) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ""; - text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + if (lineRange) { + text += themeFg("warning", lineRange); } return themeFg("muted", "read ") + text; } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 71f121a66..d4d8f3060 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -575,15 +575,7 @@ switch (name) { case 'read': { const path = shortenPath(String(args.path || args.file_path || '')); - const offset = args.offset; - const limit = args.limit; - let display = path; - if (offset !== undefined || limit !== undefined) { - const start = offset ?? 1; - const end = limit !== undefined ? start + limit - 1 : ''; - display += `:${start}${end ? `-${end}` : ''}`; - } - return `[read: ${display}]`; + return `[read: ${path}${formatReadLineRange(args.offset, args.limit)}]`; } case 'write': return `[write: ${shortenPath(String(args.path || args.file_path || ''))}]`; @@ -825,6 +817,23 @@ return null; } + // Mirrors ../tools/render-utils.ts. + function parseLineNumber(value) { + if (typeof value === 'string' && value.trim() !== '') value = Number(value); + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + function formatReadLineRange(offset, limit) { + if (offset == null && limit == null) return ''; + const start = offset == null ? 1 : parseLineNumber(offset); + if (start === undefined) return ''; + if (limit == null) return `:${start}`; + const count = parseLineNumber(limit); + if (count === undefined) return ''; + const end = start + count - 1; + return `:${start}${end ? `-${end}` : ''}`; + } + function getLanguageFromPath(filePath) { const ext = filePath.split('.').pop()?.toLowerCase(); const extToLang = { @@ -965,14 +974,10 @@ } case 'read': { const filePath = str(args.file_path ?? args.path); - const offset = args.offset; - const limit = args.limit; - + const readLineRange = formatReadLineRange(args.offset, args.limit); let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || '')); - if (filePath !== null && (offset !== undefined || limit !== undefined)) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ''; - pathHtml += `:${startLine}${endLine ? '-' + endLine : ''}`; + if (filePath !== null && readLineRange) { + pathHtml += `${escapeHtml(readLineRange)}`; } html += `
read ${pathHtml}
`; diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 6255beb44..c0fca375d 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -29,6 +29,24 @@ export function str(value: unknown): string | null { return null; } +function parseLineNumber(value: unknown): number | undefined { + if (typeof value === "string" && value.trim() !== "") value = Number(value); + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Format finite numbers or numeric strings as a read line range, omitting malformed ranges. */ +export function formatReadLineRange(offset: unknown, limit: unknown): string { + // Strict schemas represent omitted optional arguments as null. + if (offset == null && limit == null) return ""; + const start = offset == null ? 1 : parseLineNumber(offset); + if (start === undefined) return ""; + if (limit == null) return `:${start}`; + const count = parseLineNumber(limit); + if (count === undefined) return ""; + const end = start + count - 1; + return `:${start}${end ? `-${end}` : ""}`; +} + export function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } diff --git a/packages/coding-agent/src/core/tools/renderers/read.ts b/packages/coding-agent/src/core/tools/renderers/read.ts index ebf71f4a7..f69b9f2a6 100644 --- a/packages/coding-agent/src/core/tools/renderers/read.ts +++ b/packages/coding-agent/src/core/tools/renderers/read.ts @@ -16,7 +16,7 @@ import { formatPathRelativeToCwdOrAbsolute } from "../../../utils/paths.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts"; import { resolveToCwd } from "../path-utils.ts"; import type { ReadToolDetails } from "../read.ts"; -import { getTextOutput, renderToolPath, replaceTabs, str } from "../render-utils.ts"; +import { formatReadLineRange, getTextOutput, renderToolPath, replaceTabs, str } from "../render-utils.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../truncate.ts"; interface CompactReadClassification { @@ -24,17 +24,14 @@ interface CompactReadClassification { label: string; } const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); -type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number }; -function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string { - // Strict tool schemas make models send null for omitted optional fields. - if (args?.offset == null && args?.limit == null) return ""; - const startLine = args.offset ?? 1; - const endLine = args.limit != null ? startLine + args.limit - 1 : ""; - return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); +type ReadRenderArgs = { path?: string; file_path?: string; offset?: unknown; limit?: unknown }; +function styledReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string { + const range = formatReadLineRange(args?.offset, args?.limit); + return range ? theme.fg("warning", range) : ""; } function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string { const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); - return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; + return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${styledReadLineRange(args, theme)}`; } function trimTrailingEmptyLines(lines: string[]): string[] { let end = lines.length; @@ -96,7 +93,7 @@ function formatCompactReadCall( return ( theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageText", classification.label) + - formatReadLineRange(args, theme) + + styledReadLineRange(args, theme) + expandHint ); } @@ -105,7 +102,7 @@ function formatCompactReadCall( theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) + " " + theme.fg("accent", classification.label) + - formatReadLineRange(args, theme) + + styledReadLineRange(args, theme) + expandHint ); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index bd48684cf..0b8fb1dd2 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -390,6 +390,7 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; +export { formatReadLineRange } from "./core/tools/render-utils.ts"; export { hasTrustRequiringProjectResources, type ProjectTrustDecision, diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 577bfa289..767740c87 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -13,6 +13,7 @@ import { wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import type { SessionTreeNode } from "../../../core/session-manager.ts"; +import { formatReadLineRange } from "../../../core/tools/render-utils.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { formatKeyText, keyHint } from "./keybinding-hints.ts"; @@ -953,15 +954,7 @@ class TreeList implements Component { switch (name) { case "read": { const path = shortenPath(String(args.path || args.file_path || "")); - const offset = args.offset as number | undefined; - const limit = args.limit as number | undefined; - let display = path; - if (offset !== undefined || limit !== undefined) { - const start = offset ?? 1; - const end = limit !== undefined ? start + limit - 1 : ""; - display += `:${start}${end ? `-${end}` : ""}`; - } - return `[read: ${display}]`; + return `[read: ${path}${formatReadLineRange(args.offset, args.limit)}]`; } case "write": { const path = shortenPath(String(args.path || args.file_path || "")); diff --git a/packages/coding-agent/test/read-line-range-display.test.ts b/packages/coding-agent/test/read-line-range-display.test.ts new file mode 100644 index 000000000..738d11c0e --- /dev/null +++ b/packages/coding-agent/test/read-line-range-display.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stripVTControlCharacters } from "node:util"; +import { runInNewContext } from "node:vm"; +import type { TUI } from "@earendil-works/pi-tui"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { exportFromFile } from "../src/core/export-html/index.ts"; +import { + CURRENT_SESSION_VERSION, + type SessionHeader, + SessionManager, + type SessionMessageEntry, +} from "../src/core/session-manager.ts"; +import { createReadToolDefinition } from "../src/core/tools/read.ts"; +import { formatReadLineRange } from "../src/core/tools/render-utils.ts"; +import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts"; +import { TreeSelectorComponent } from "../src/modes/interactive/components/tree-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +interface HtmlViewer { + formatReadLineRange: typeof formatReadLineRange; + getTreeNodeDisplayHtml(entry: SessionMessageEntry): string; + renderEntry(entry: SessionMessageEntry): string; +} + +// Runs the exported viewer's real data loading and renderers, stopping before DOM initialization. +function loadHtmlViewer(html: string): HtmlViewer { + const sessionData = html.match(/