UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

153 lines • 6.48 kB
import { pathToFileURL } from "node:url"; import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui"; import { stripAnsi } from "../../utils/ansi.js"; import { resolvePath, shortenHomePath } from "../../utils/paths.js"; import { sanitizeBinaryOutput } from "../../utils/shell.js"; export function shortenPath(path) { if (typeof path !== "string") return ""; return shortenHomePath(path); } export function linkPath(styledText, rawPath, cwd) { if (!getCapabilities().hyperlinks) return styledText; const absolutePath = resolvePath(rawPath, cwd); return hyperlink(styledText, pathToFileURL(absolutePath).href); } export function str(value) { if (typeof value === "string") return value; if (value == null) return ""; return null; } function parseLineNumber(value) { 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, limit) { // 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) { return text.replace(/\t/g, " "); } export function normalizeDisplayText(text) { return text.replace(/\r/g, ""); } export function getTextOutput(result, showImages) { if (!result) return ""; const textBlocks = result.content.filter((c) => c.type === "text"); const imageBlocks = result.content.filter((c) => c.type === "image"); let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n"); const caps = getCapabilities(); if (imageBlocks.length > 0 && (!caps.images || !showImages)) { const imageIndicators = imageBlocks .map((img) => { const mimeType = img.mimeType ?? "image/unknown"; const dims = img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined; return imageFallback(mimeType, dims); }) .join("\n"); output = output ? `${output}\n${imageIndicators}` : imageIndicators; } return output; } const COLLAPSED_ARGS_CHARS = 100; /** * Generic tool call header: the title followed by the arguments. Collapsed, they are `key=value` * pairs on the title line, cut to {@link COLLAPSED_ARGS_CHARS}. Expanded, each is a `key: value` * line below the title, with strings shown raw and continuation lines indented. */ export function formatToolCallWithArgs(title, args, theme, expanded) { const header = theme.fg("toolTitle", theme.bold(title)); if (args == null) return header; const entries = typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]]; if (entries.length === 0) return header; if (expanded) { const lines = entries.map(([key, value]) => { const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value)); return ` ${key}: ${replaceTabs(text).replace(/\r/g, "").split("\n").join("\n ")}`; }); return `${header}\n${theme.fg("muted", lines.join("\n"))}`; } const pairs = entries.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`).join(" "); const preview = pairs.length > COLLAPSED_ARGS_CHARS ? `${pairs.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : pairs; return `${header} ${theme.fg("muted", preview)}`; } /** Duration as `4.2s` below a minute, then `1m 30s` and `2h 3m 4s`. */ export function formatToolDuration(ms) { const seconds = ms / 1000; if (seconds < 60) return `${seconds.toFixed(1)}s`; const totalSeconds = Math.floor(seconds); const minutes = Math.floor(totalSeconds / 60); const remainder = totalSeconds % 60; if (minutes < 60) return `${minutes}m ${remainder}s`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`; } /** Start the row's clock once execution starts. Call from `renderCall`. */ export function startToolTimer(context) { const state = context.state; if (context.executionStarted && state.startedAt === undefined) { state.startedAt = Date.now(); state.endedAt = undefined; } } /** * The row's timing line, from `renderResult`: `Elapsed 4.2s` while the result is partial, redrawn * every second, and `Took 4.2s` once it is final. Undefined when the row has no start time and no * recorded duration. */ export function toolTimerText(context, isPartial) { const state = context.state; if (state.startedAt !== undefined && isPartial && !state.interval) { state.interval = setInterval(() => context.invalidate(), 1000); } if (!isPartial || context.isError) { state.endedAt ??= Date.now(); if (state.interval) { clearInterval(state.interval); state.interval = undefined; } } // A final result's recorded duration wins: it is monotonic and survives reloads. The renderer's own clock is the // fallback for live progress and for results stored without one. if (!isPartial && context.durationMs !== undefined) return `Took ${formatToolDuration(context.durationMs)}`; if (state.startedAt === undefined) return undefined; const endTime = state.endedAt ?? Date.now(); return `${isPartial ? "Elapsed" : "Took"} ${formatToolDuration(endTime - state.startedAt)}`; } export function invalidArgText(theme) { return theme.fg("error", "[invalid arg]"); } export function renderToolPath(rawPath, theme, cwd, options) { if (rawPath === null) return invalidArgText(theme); const value = rawPath || options?.emptyFallback; if (!value) return theme.fg("toolOutput", "..."); return linkPath(theme.fg("accent", shortenPath(value)), value, cwd); } //# sourceMappingURL=render-utils.js.map