diff --git a/.changeset/collapsed-tool-cards.md b/.changeset/collapsed-tool-cards.md new file mode 100644 index 000000000..dea96762c --- /dev/null +++ b/.changeset/collapsed-tool-cards.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Collapsed tool cards now show a short outcome row and a width-aware header. diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 37aefcf66..6acfff4e6 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -59,6 +59,8 @@ const GOAL_TIMER_INTERVAL_MS = 1_000; const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; +export type ToolOutputExpandHint = 'expand' | 'collapse'; + /** * Expand tips into a rotation sequence using smooth weighted round-robin * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while @@ -222,6 +224,7 @@ export class FooterComponent implements Component { */ private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; + private expandHintProvider: (() => ToolOutputExpandHint | null) | null = null; constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; @@ -300,6 +303,10 @@ export class FooterComponent implements Component { * count produces its own bracketed badge on line 1; zeros hide them * independently. */ + setExpandHintProvider(provider: () => ToolOutputExpandHint | null): void { + this.expandHintProvider = provider; + } + setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void { this.backgroundBashTaskCount = Math.max(0, counts.bashTasks); this.backgroundAgentCount = Math.max(0, counts.agentTasks); @@ -345,26 +352,20 @@ export class FooterComponent implements Component { const leftWidth = visibleWidth(leftLine); - // Rotating hint tips stay on the right unless they were given an - // inline slot in items (rendered above at their configured position) - // or the user dropped 'tips' from items. - let tipText = ''; const tipsInline = order.includes('tips'); const showTips = !tipsInline && (configured === null || configured.includes('tips')); + const tipCandidates: string[] = []; if (showTips) { const { primary, pair } = tipsForIndex(currentTipIndex()); - const gap = 2; - const remaining = Math.max(0, width - leftWidth - gap); - if (pair && visibleWidth(pair) <= remaining) { - tipText = pair; - } else if (primary && visibleWidth(primary) <= remaining) { - tipText = primary; - } + if (pair) tipCandidates.push(pair); + if (primary) tipCandidates.push(primary); } + const remaining = Math.max(0, width - leftWidth - 2); + const rightText = this.buildRightText(tipCandidates, remaining, colors); - if (tipText) { - const pad = width - leftWidth - visibleWidth(tipText); - line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + if (rightText.length > 0) { + const pad = width - leftWidth - visibleWidth(rightText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + rightText; } else if (leftWidth <= width) { line1 = leftLine; } else { @@ -395,8 +396,14 @@ export class FooterComponent implements Component { chalk.hex(colors.text)(contextText) + chalk.hex(colors.textDim)(speedSuffix); } else { - const leftPad = Math.max(0, width - rightWidth); + const shortcut = customLine !== null ? this.expandShortcut() : null; + const left = + shortcut !== null && visibleWidth(shortcut) + 1 + rightWidth <= width + ? chalk.hex(colors.textDim)(shortcut) + : ''; + const leftPad = Math.max(0, width - visibleWidth(left) - rightWidth); line2 = + left + ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText) + chalk.hex(colors.textDim)(speedSuffix); @@ -405,6 +412,28 @@ export class FooterComponent implements Component { return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; } + private expandShortcut(): string | null { + const hint = this.expandHintProvider?.() ?? null; + return hint === null ? null : `ctrl+o ${hint}`; + } + + private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string { + const shortcut = this.expandShortcut(); + if (shortcut === null) { + const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining); + return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip); + } + for (const tip of tips) { + if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) { + return ( + chalk.hex(colors.textDim)(shortcut) + + chalk.hex(colors.textMuted)(`${TIP_SEPARATOR}${tip}`) + ); + } + } + return visibleWidth(shortcut) <= remaining ? chalk.hex(colors.textDim)(shortcut) : ''; + } + /** * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, * outside a git repo) yield an empty list so composition just skips them. diff --git a/apps/pythinker-code/src/tui/components/messages/read-group.ts b/apps/pythinker-code/src/tui/components/messages/read-group.ts index 4cb1f2b02..11c9d8255 100644 --- a/apps/pythinker-code/src/tui/components/messages/read-group.ts +++ b/apps/pythinker-code/src/tui/components/messages/read-group.ts @@ -27,6 +27,7 @@ import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; const THROTTLE_MS = 200; @@ -37,7 +38,7 @@ interface ReadEntry { export class ReadGroupComponent extends Container { private readonly entries: ReadEntry[] = []; - private readonly headerText: Text; + private readonly headerText: TruncatedHeaderLine; private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); @@ -46,7 +47,7 @@ export class ReadGroupComponent extends Container { constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); - this.headerText = new Text('', 0, 0); + this.headerText = new TruncatedHeaderLine(''); this.addChild(this.headerText); this.bodyContainer = new Container(); this.addChild(this.bodyContainer); @@ -130,7 +131,12 @@ export class ReadGroupComponent extends Container { this.ui?.requestRender(); } - private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { + private buildHeader( + total: number, + pending: number, + failed: number, + totalLines: number, + ): HeaderContent { const dim = (text: string): string => currentTheme.dim(text); if (pending > 0) { @@ -139,7 +145,6 @@ export class ReadGroupComponent extends Container { return `${bullet}${label}`; } - // All reads have finished, either successfully or with failures. if (failed === total) { const bullet = currentTheme.fg('error', '✗ '); const label = currentTheme.boldFg('error', `Read ${String(total)} files`); @@ -149,8 +154,12 @@ export class ReadGroupComponent extends Container { const bullet = currentTheme.fg('success', STATUS_BULLET); const label = currentTheme.boldFg('textStrong', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; - return `${bullet}${label}${linesPart}${failPart}`; + if (failed === 0) return `${bullet}${label}${linesPart}`; + return { + head: `${bullet}${label}`, + flex: { text: ` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`, style: dim, keep: 'head' }, + tail: currentTheme.fg('error', ` · ${String(failed)} failed`), + }; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { diff --git a/apps/pythinker-code/src/tui/components/messages/shell-execution.ts b/apps/pythinker-code/src/tui/components/messages/shell-execution.ts index 71a02b375..ecef36180 100644 --- a/apps/pythinker-code/src/tui/components/messages/shell-execution.ts +++ b/apps/pythinker-code/src/tui/components/messages/shell-execution.ts @@ -5,7 +5,8 @@ import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; -import { PREVIEW_LINES } from './tool-renderers/types'; +import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types'; +import { outcomeRows } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { @@ -60,6 +61,12 @@ export class ShellExecutionComponent extends Container { } } + wasTruncated(): boolean { + return this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ); + } + private addResultPreview( result: ToolResultBlockData, expanded: boolean, @@ -85,13 +92,16 @@ export const shellExecutionResultRenderer: ResultRenderer = ( _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, -): Component[] => [ - // Result only. The command preview is owned by ToolCallComponent's - // buildCallPreview across the whole lifecycle (streaming, running, and - // done); rendering it here too would duplicate the command once the result - // lands. - new ShellExecutionComponent({ - result, - expanded: ctx.expanded, - }), -]; +): Component[] => { + if (!ctx.expanded && result.is_error !== true) { + const leadsWithMetadata = + result.output.startsWith('task_id:') || isSpilledToolOutput(result.output); + return outcomeRows(result.output, leadsWithMetadata ? 'first' : 'last'); + } + return [ + new ShellExecutionComponent({ + result, + expanded: ctx.expanded, + }), + ]; +}; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index 67ce52455..2cb7869dd 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -13,6 +13,7 @@ import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS, COMMAND_PREVIEW_LINES, + OUTCOME_MAX_LINES, RESULT_PREVIEW_LINES, THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; @@ -33,10 +34,18 @@ import { formatTokenCount } from '#/utils/usage/usage-format'; import { agentDynamicWorkflowResultSummaryFromOutput } from './agent-dynamic-workflow-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; -import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; -import { buildGoalToolHeader } from './tool-renderers/goal'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; +import { computeWriteStats, countNonEmptyLines, pickChip } from './tool-renderers/chip'; +import { searchNoticeOnly } from './tool-renderers/grep-output'; +import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal'; +import { parseReadMediaOutput } from './tool-renderers/media'; +import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; -import { buildWaitForHeader } from './tool-renderers/wait-for'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; +import { isSpilledToolOutput } from './tool-renderers/types'; +import { buildWaitForHeader, parseWaitForOutput } from './tool-renderers/wait-for'; + +const dimHeaderStyle = (text: string): string => currentTheme.dim(text); const MAX_ARG_LENGTH = 60; const MAX_SUB_TOOL_CALLS_SHOWN = 4; @@ -405,18 +414,20 @@ function formatKeyArgument( key: string, value: string, workspaceDir: string | undefined, + truncate: boolean, ): string { const displayValue = toolName === 'Read' && PATH_KEYS.has(key) ? makeWorkspaceRelativePath(value, workspaceDir) : value; - return truncateArgValue(key, displayValue); + return truncate ? truncateArgValue(key, displayValue) : displayValue; } export function extractKeyArgument( toolName: string, args: Record, workspaceDir?: string, + truncate = true, ): string | null { const keyMap: Record = { Bash: ['command'], @@ -445,7 +456,7 @@ export function extractKeyArgument( if (args['include_ignored'] === true) { summary += ' · include ignored'; } - return truncateArgValue('pattern', summary); + return truncate ? truncateArgValue('pattern', summary) : summary; } const candidates = keyMap[toolName] ?? Object.keys(args); @@ -455,12 +466,23 @@ export function extractKeyArgument( const firstLine = val.split('\n')[0] ?? val; const displayValue = toolName === 'Bash' && val.includes('\n') ? `${firstLine}…` : firstLine; - return formatKeyArgument(toolName, key, displayValue, workspaceDir); + return formatKeyArgument(toolName, key, displayValue, workspaceDir, truncate); } } return null; } +export function extractKeyArgumentDetail( + toolName: string, + args: Record, + workspaceDir?: string, +): { text: string; keep: 'head' | 'tail' } | null { + const text = extractKeyArgument(toolName, args, workspaceDir, false); + if (text === null) return null; + const keep = toolName === 'Read' || toolName === 'Write' || toolName === 'Edit' ? 'tail' : 'head'; + return { text, keep }; +} + function formatSubagentLabel(agentName: string | undefined): string { const raw = agentName?.trim(); if (raw === undefined || raw.length === 0) return 'SubAgent'; @@ -540,6 +562,8 @@ export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; private readonly markdownTheme = createMarkdownTheme(); + private hiddenContent: boolean | undefined = undefined; + private truncatedAtLastRender = false; private result: ToolResultBlockData | undefined; private ui: TUI | undefined; private planPath: string | undefined; @@ -551,7 +575,7 @@ export class ToolCallComponent extends Container { * the plan body even without a `## Approved Plan:` marker. */ private currentPlan: string | undefined; - private headerText: Text; + private headerText: TruncatedHeaderLine; private callPreviewEndIndex = 0; // ── Subagent state ─────────────────────────────────────────────── @@ -655,7 +679,7 @@ export class ToolCallComponent extends Container { this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); - this.headerText = new Text(this.buildHeader(), 0, 0); + this.headerText = new TruncatedHeaderLine(this.buildHeader()); this.addChild(this.headerText); this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; @@ -695,6 +719,18 @@ export class ToolCallComponent extends Container { i++; } + if (!this.expanded) { + this.truncatedAtLastRender = this.children.some( + (child, index) => + (child instanceof TruncatedHeaderLine && + (index !== 1 || + (this.toolCall.name === 'Bash' && this.toolCall.truncated !== true)) && + child.wasTruncated()) || + ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && + child.wasTruncated()), + ); + } + if (allReused) { return cache!.lines; } @@ -746,6 +782,105 @@ export class ToolCallComponent extends Container { this.rebuildBody(); } + hasHiddenContent(): boolean { + this.hiddenContent ??= this.computeHiddenContent(); + return this.hiddenContent || this.truncatedAtLastRender; + } + + isExpanded(): boolean { + return this.expanded; + } + + private computeHiddenContent(): boolean { + const { name, args } = this.toolCall; + if (this.isSingleSubagentView()) return false; + if (this.toolCall.truncated === true && this.result === undefined) return false; + if (this.result === undefined && this.toolCall.streamingArguments !== undefined) { + return ( + name === 'Bash' && + (extractPartialStringField(this.toolCall.streamingArguments, 'command') ?? '').length > 0 + ); + } + if (this.callPreviewHidesContent()) return true; + const { result } = this; + if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; + if (result.output.length === 0) return false; + if (result.output.trimStart().startsWith('')) return false; + if (result.is_error === true) return nonEmptyLines(result.output).length > RESULT_PREVIEW_LINES; + switch (name) { + case 'ReadMediaFile': + return ( + parseReadMediaOutput(result.output) !== null || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Grep': + case 'Glob': + return ( + !searchNoticeOnly(this.toolCall, result.output) || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'WaitFor': + return ( + parseWaitForOutput(result.output) !== undefined || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Read': + case 'FetchURL': + case 'WebSearch': + case 'Think': + return true; + case 'ExitPlanMode': + return ( + !isExitPlanModeOutcomeOutput(result.output) && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'AskUserQuestion': + return ( + args['background'] === true && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'CreateGoal': + case 'GetGoal': + return ( + parseGoalToolOutput(result.output) === undefined && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Edit': + case 'Write': + case 'SetGoalBudget': + case 'UpdateGoal': + case 'AgentSwarm': + case 'TodoList': + case 'EnterPlanMode': + return false; + default: + return nonEmptyLines(result.output).length > OUTCOME_MAX_LINES; + } + } + + private callPreviewHidesContent(): boolean { + const { name, args } = this.toolCall; + switch (name) { + case 'Bash': + return str(args['command']).includes('\n'); + case 'Edit': { + const oldStr = str(args['old_string']); + const newStr = str(args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return false; + const filePath = str(args['file_path'] ?? args['path']); + const full = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }); + const capped = renderDiffLinesClustered(oldStr, newStr, filePath, { + contextLines: 3, + maxLines: COMMAND_PREVIEW_LINES, + }); + return capped.length !== full.length || capped.at(-1) !== full.at(-1); + } + case 'Write': + return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; + default: + return false; + } + } + setResult(result: ToolResultBlockData): void { this.result = result; // Result supersedes any live progress chatter; the result body is the @@ -1461,7 +1596,7 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } - private buildHeader(): string { + private buildHeader(): HeaderContent { const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; @@ -1515,17 +1650,20 @@ export class ToolCallComponent extends Container { } if (toolCall.name === 'Bash') { - // The command itself is rendered in the body (with a `$` prompt), so the - // header only names the action — repeating the command in parentheses - // would duplicate the body. Wording mirrors the other label-only headers - // (e.g. AskUserQuestion): the whole label takes the tone colour. if (isTruncated) { return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; } const label = isFinished ? 'Ran a command' : 'Running a command'; const tone = isError ? 'error' : 'textStrong'; + const command = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; - return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (command === null) return `${head}${chipStr}`; + return { + head: `${head}${currentTheme.dim(' · $ ')}`, + flex: { text: command.text, style: dimHeaderStyle, keep: 'head' }, + tail: chipStr, + }; } const goalHeader = buildGoalToolHeader({ @@ -1549,7 +1687,7 @@ export class ToolCallComponent extends Container { } const verb = isFinished ? 'Used' : isTruncated ? 'Truncated' : 'Using'; - const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); + const keyArg = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated ? currentTheme.fg('error', verb) @@ -1558,13 +1696,19 @@ export class ToolCallComponent extends Container { decoded !== null ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` : currentTheme.boldFg('primary', toolCall.name); - const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); - return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; + const head = `${bullet}${verbStyled} ${toolLabel}`; + if (keyArg === null) return `${head}${chipStr}`; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: keyArg.text, style: dimHeaderStyle, keep: keyArg.keep }, + tail: `${currentTheme.dim(')')}${chipStr}`, + }; } private buildHeaderChip(result: ToolResultBlockData): string { + if (isSpilledToolOutput(result.output)) return ''; const provider = pickChip(this.toolCall.name); if (provider === undefined) return ''; const text = provider(this.toolCall, result); @@ -1574,6 +1718,7 @@ export class ToolCallComponent extends Container { } private rebuildContent(): void { + this.hiddenContent = undefined; while (this.children.length > this.callPreviewEndIndex) { this.children.pop(); } @@ -1585,6 +1730,7 @@ export class ToolCallComponent extends Container { } private rebuildBody(): void { + this.hiddenContent = undefined; while (this.children.length > 2) { this.children.pop(); } @@ -1630,6 +1776,14 @@ export class ToolCallComponent extends Container { private buildLiveOutputBlock(): void { if (this.result !== undefined) return; if (this.liveOutput.length === 0) return; + if (!this.expanded) { + const lines = nonEmptyLines(this.liveOutput); + const latest = lines.at(-1); + if (latest !== undefined) { + this.addChild(outcomeLine(latest, lines.length > 1 ? 'above' : undefined)); + } + return; + } this.addChild( new ShellExecutionComponent({ result: { @@ -1637,10 +1791,7 @@ export class ToolCallComponent extends Container { output: this.liveOutput, is_error: false, }, - expanded: this.expanded, - resultPreviewLines: RESULT_PREVIEW_LINES, - tailOutput: true, - expandHint: false, + expanded: true, }), ); } @@ -2063,21 +2214,14 @@ export class ToolCallComponent extends Container { this.addChild(new Text(line, 2, 0)); } } else if (name === 'Bash') { - // Surface the command in the body across the whole lifecycle — while - // streaming, running, and after the result lands. Keeping the collapsed - // command preview here (instead of yielding to the result renderer once - // the result lands) avoids a height collapse when a multi-line command - // finishes with short output: the command block stays put and only the - // live-output tail swaps for the result. Owned solely by buildCallPreview - // so the command never renders twice; shellExecutionResultRenderer - // renders the result only. + if (!this.expanded) return; const command = str(this.toolCall.args['command']); if (command.length === 0) return; this.addChild( new ShellExecutionComponent({ command, showCommand: true, - commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + commandPreviewLines: undefined, }), ); } diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts index 3c8ead752..80e3beaf9 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts @@ -9,11 +9,14 @@ */ import { computeDiffLines } from '#/tui/components/media/diff-preview'; +import { OUTCOME_MAX_LINES } from '#/tui/constant/rendering'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { goalStatusChip } from './goal'; +import { parseGlobOutput, parseGrepOutput, searchNoticeOnly } from './grep-output'; import { readMediaChip } from './media'; -import { strArg } from './types'; +import { nonEmptyLines } from './outcome'; +import { strArg, stripSpillPointer } from './types'; import { waitForChip } from './wait-for'; export type ChipProvider = (toolCall: ToolCallBlockData, result: ToolResultBlockData) => string; @@ -25,8 +28,9 @@ export function countNonEmptyLines(text: string): number { return n; } -function pluralize(n: number, singular: string, plural?: string): string { - return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +// `partial` marks a lower bound (`12+ files`) when the tool reported an incomplete result set. +function pluralize(n: number, singular: string, plural?: string, partial = false): string { + return `${String(n)}${partial ? '+' : ''} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; } function formatBytes(bytes: number): string { @@ -85,47 +89,51 @@ const editChip: ChipProvider = (toolCall) => { const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats(toolCall.args)); const readChip: ChipProvider = (_toolCall, result) => - pluralize(countNonEmptyLines(result.output), 'line'); + pluralize(nonEmptyLines(result.output).length, 'line'); + +// A collapsed Bash card shows its output whole when it fits the outcome +// rows; once one line stands in for the rest, the chip counts the hidden +// lines, not the total. A failed command keeps its multi-line preview, whose +// own trailer already counts what is left, so the chip stays out of its way. +const bashChip: ChipProvider = (_toolCall, result) => { + if (result.is_error === true) return ''; + // Counted the way the outcome rows are, so whitespace-only rows neither + // count as hidden nor leave the chip claiming more than the card holds. + const lines = nonEmptyLines(result.output).length; + return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines - 1, 'more line'); +}; -const grepChip: ChipProvider = (_toolCall, result) => { - const matches = countNonEmptyLines(result.output); - if (matches === 0) return 'no matches'; - return pluralize(matches, 'match', 'matches'); +// Grep's default mode lists files, so the chip counts what the mode +// returns: files, or matches and the files they fall in. Unnumbered content +// with context flags mixes match and context rows, so only the file count +// is exact there. +const grepChip: ChipProvider = (toolCall, result) => { + // A notice-only result (cut short, or only filtered sensitive files) is not + // an empty search; the glance shows the notice and the chip stays out of + // its way. A paginated count-mode page past the last row still carries + // the totals, so the emptiness check reads the summary-backed file count. + if (searchNoticeOnly(toolCall, result.output)) return ''; + const stats = parseGrepOutput(toolCall, result.output); + if (stats.files === 0) return 'no matches'; + if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file', undefined, stats.partial); + if (stats.matches === null) return pluralize(stats.files, 'file', undefined, stats.partial); + const matches = pluralize(stats.matches, 'match', 'matches', stats.partial); + // A paginated content result only shows the files on its page. + if (stats.filesPartial) return matches; + return stats.files === 1 + ? `${matches} in 1 file` + : `${matches} across ${pluralize(stats.files, 'file', undefined, stats.partial)}`; }; -const GLOB_PAGE_HEADER = - /^Showing matches (\d+)\u2013(\d+) of (\d+)( collected matches \(partial result set\))?\.$/; -const GLOB_EMPTY_NOTICE = - /^(?:No more matches at offset=\d+ in the (?:current|collected partial) result set \(\d+ matches\)\.|No matches collected; search incomplete\.|No non-sensitive matches found \(\d+ sensitive file\(s\) filtered\)\.|No matches found)$/; -const GLOB_FOOTER = /^(?:Filtered \d+ sensitive file\(s\)\.|Found \d+ matches)$/; - -const globChip: ChipProvider = (_toolCall, result) => { - const lines = result.output.split('\n'); - let files = 0; - let partial = false; - let counted = false; - for (const line of lines) { - if (GLOB_EMPTY_NOTICE.test(line)) return 'no files'; - const page = GLOB_PAGE_HEADER.exec(line); - if (page === null) continue; - files = Number(page[2]) - Number(page[1]) + 1; - partial = Number(page[2]) < Number(page[3]) || page[4] !== undefined; - counted = true; - break; - } - if (!counted) { - for (const line of lines) { - if (line.trim().length === 0) continue; - if (GLOB_FOOTER.test(line)) continue; - files++; - } - } - if (files === 0) return 'no files'; - return `${String(files)}${partial ? '+' : ''} ${files === 1 ? 'file' : 'files'}`; +const globChip: ChipProvider = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return ''; + const { entries, partial } = parseGlobOutput(result.output); + if (entries.length === 0) return 'no files'; + return pluralize(entries.length, 'file', undefined, partial); }; const fetchChip: ChipProvider = (_toolCall, result) => - formatBytes(Buffer.byteLength(result.output, 'utf8')); + formatBytes(Buffer.byteLength(stripSpillPointer(result.output), 'utf8')); const webSearchChip: ChipProvider = (_toolCall, result) => { const lines = result.output.split('\n').filter((l) => l.trim().length > 0); @@ -141,6 +149,7 @@ const goalStatusOutputChip: ChipProvider = (_toolCall, result) => result.is_error ? '' : goalStatusChip(result.output); const REGISTRY: Record = { + Bash: bashChip, Edit: editChip, Write: writeChip, Read: readChip, diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts index cf4a3ac22..53cbdfef4 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts @@ -160,7 +160,7 @@ function formatGoalToolArgument( } } -function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { +export function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { const goal = parseGoalValue(output); if (goal === undefined || goal === null) return goal; const objective = stringField(goal, 'objective'); diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/grep-output.ts new file mode 100644 index 000000000..3b99869f0 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -0,0 +1,167 @@ +import type { ToolCallBlockData } from '#/tui/types'; + +import { strArg, stripSpillPointer } from './types'; + +export type GrepMode = 'files_with_matches' | 'content' | 'count_matches'; + +export interface GrepEntry { + readonly path: string; + readonly label: string; +} + +export interface GrepStats { + readonly mode: GrepMode; + readonly entries: readonly GrepEntry[]; + readonly total: number; + readonly matches: number | null; + readonly files: number; + readonly filesPartial: boolean; + readonly partial: boolean; +} + +export interface GlobStats { + readonly entries: readonly string[]; + readonly partial: boolean; +} + +const NOTICE = + /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Found \d+ matches$|Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at |Only the first |rg: |Showing matches \d+)/; + +const GLOB_META = + /^(?:Showing matches \d+|Continue with the same search arguments|To remove the match-count limit|No more matches at offset=|No matches collected; search incomplete)/; + +const COUNT_SUMMARY = /^Found (\d+) total (?:non-sensitive )?occurrences? across (\d+) files?\.$/m; +const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; +const INCOMPLETE = + /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; + +const GLOB_PAGE_HEADER = + /^Showing matches (\d+)\u2013(\d+) of (\d+)( collected matches \(partial result set\))?\.$/; + +const CONTENT_MATCH = /^(.+?):(\d+):/; +const COUNT_LINE = /^(.+):(\d+)$/; +const DRIVE_PREFIX = /^[A-Za-z]:[\\/]/; + +function resultLines(output: string): string[] { + if (output.length === 0) return []; + return stripSpillPointer(output) + .split('\n') + .filter((line) => line.length > 0 && line !== '--' && !NOTICE.test(line)); +} + +export function grepMode(toolCall: ToolCallBlockData): GrepMode { + const mode = strArg(toolCall.args, 'output_mode'); + return mode === 'content' || mode === 'count_matches' ? mode : 'files_with_matches'; +} + +export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): GrepStats { + const mode = grepMode(toolCall); + const lines = resultLines(output); + const partial = INCOMPLETE.test(output); + + if (mode === 'files_with_matches') { + const entries = lines.map((path) => ({ path, label: path })); + const total = PAGINATION_TOTAL.exec(output)?.[1]; + const files = total === undefined ? entries.length : Number(total); + return { mode, entries, total: files, matches: files, files, filesPartial: false, partial }; + } + + if (mode === 'count_matches') { + const entries: GrepEntry[] = []; + let matches = 0; + for (const line of lines) { + const [, path, count] = COUNT_LINE.exec(line) ?? []; + if (path === undefined || count === undefined) continue; + entries.push({ path, label: line }); + matches += Number(count); + } + const [, totalMatches, totalFiles] = COUNT_SUMMARY.exec(output) ?? []; + if (totalMatches !== undefined && totalFiles !== undefined) { + return { + mode, + entries, + total: Number(totalFiles), + matches: Number(totalMatches), + files: Number(totalFiles), + filesPartial: false, + partial, + }; + } + return { + mode, + entries, + total: entries.length, + matches, + files: entries.length, + filesPartial: false, + partial, + }; + } + + const numbered = toolCall.args['-n'] !== false; + const positive = (flag: string): boolean => { + const value = toolCall.args[flag]; + return typeof value === 'number' && value > 0; + }; + const hasContext = + typeof toolCall.args['-C'] === 'number' ? positive('-C') : positive('-A') || positive('-B'); + const countable = numbered || !hasContext; + const entries: GrepEntry[] = []; + const paths = new Set(); + let rows = 0; + for (const line of lines) { + if (numbered) { + const [, path, lineNumber] = CONTENT_MATCH.exec(line) ?? []; + if (path === undefined || lineNumber === undefined) continue; + rows++; + paths.add(path); + entries.push({ path, label: `${path}:${lineNumber}` }); + continue; + } + const idx = line.indexOf(':', DRIVE_PREFIX.test(line) ? 2 : 0); + const path = idx > 0 ? line.slice(0, idx) : line; + rows++; + if (paths.has(path)) continue; + paths.add(path); + entries.push({ path, label: path }); + } + const paginatedTotal = hasContext ? undefined : PAGINATION_TOTAL.exec(output)?.[1]; + const matches = countable ? (paginatedTotal === undefined ? rows : Number(paginatedTotal)) : null; + return { + mode, + entries, + total: numbered && matches !== null ? matches : paths.size, + matches, + files: paths.size, + filesPartial: paginatedTotal !== undefined, + partial, + }; +} + +function globPagePartial(output: string): boolean { + for (const line of output.split('\n')) { + const page = GLOB_PAGE_HEADER.exec(line); + if (page === null) continue; + const end = Number(page[2]); + const total = Number(page[3]); + return end < total || page[4] !== undefined; + } + return false; +} + +export function parseGlobOutput(output: string): GlobStats { + return { + entries: resultLines(output).filter((line) => !GLOB_META.test(line)), + partial: INCOMPLETE.test(output) || globPagePartial(output), + }; +} + +const SENSITIVE_ONLY = /^No non-sensitive matches found/m; + +export function searchNoticeOnly(toolCall: ToolCallBlockData, output: string): boolean { + const noRows = + toolCall.name === 'Glob' + ? parseGlobOutput(output).entries.length === 0 + : parseGrepOutput(toolCall, output).entries.length === 0; + return noRows && (INCOMPLETE.test(output) || SENSITIVE_ONLY.test(output)); +} diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/outcome.ts new file mode 100644 index 000000000..c5f08c0cc --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -0,0 +1,44 @@ +import type { Component } from '@pymodel/pi-tui'; + +import { + OUTCOME_MAX_LINES, + OUTCOME_ROW_INDENT, + TRUNCATION_ELLIPSIS, +} from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; + +import { TruncatedHeaderLine } from '../truncated-header-line'; +import { stripSpillPointer } from './types'; + +const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); + +export function nonEmptyLines(text: string): string[] { + return sanitizeShellOutput(stripSpillPointer(text)) + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => line.trimEnd()); +} + +export function outcomeRow(head: string, text: string, tail: string): Component { + return new TruncatedHeaderLine({ + head, + flex: { text, style: dimOutcomeStyle, keep: 'head' }, + tail: tail.length > 0 ? dimOutcomeStyle(tail) : '', + }); +} + +export function outcomeLine(text: string, more?: 'above' | 'below'): Component { + return outcomeRow( + more === 'above' ? `${OUTCOME_ROW_INDENT}${TRUNCATION_ELLIPSIS} ` : OUTCOME_ROW_INDENT, + text, + more === 'below' ? ` ${TRUNCATION_ELLIPSIS}` : '', + ); +} + +export function outcomeRows(output: string, keep: 'first' | 'last'): Component[] { + const lines = nonEmptyLines(output); + if (lines.length <= OUTCOME_MAX_LINES) return lines.map((line) => outcomeLine(line)); + const line = keep === 'first' ? lines[0] : lines.at(-1); + return line === undefined ? [] : [outcomeLine(line, keep === 'first' ? 'below' : 'above')]; +} diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts index eedc4316a..655253d1a 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts @@ -15,14 +15,13 @@ import { shellExecutionResultRenderer } from '../shell-execution'; import { goalSummary } from './goal'; import { waitForSummary } from './wait-for'; import { - editSummary, fetchSummary, + fileChangeSummary, globSummary, grepSummary, readSummary, thinkSummary, webSearchSummary, - writeSummary, } from './summary'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; @@ -56,9 +55,8 @@ export function pickResultRenderer(toolName: string): ResultRenderer { case 'Think': return thinkSummary; case 'Edit': - return editSummary; case 'Write': - return writeSummary; + return fileChangeSummary; case 'CreateGoal': case 'GetGoal': case 'SetGoalBudget': diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts index 206bf0e2d..322055ce3 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts @@ -1,10 +1,9 @@ /** - * Summary-style renderers — produce optional inline-glance content for - * tools whose raw output is high-volume but low-information (Grep, - * Glob). The numeric summary (line counts, exit codes, sizes) lives in - * the header chip (see chip.ts), so most tools intentionally render an - * empty body and only expose details when the global expand toggle is - * on. + * Summary-style renderers — produce an inline glance for tools whose raw + * output is high-volume but low-information (Grep, Glob). The numeric + * summary (line counts, sizes) lives in the header chip (see chip.ts); the + * glance is the collapsed card's outcome row, and the raw output only + * appears when the global expand toggle is on. * * Errors always fall through to the truncated renderer so the user * sees the actual error message, not a synthetic summary. @@ -12,67 +11,80 @@ import type { Component } from '@pymodel/pi-tui'; import { Text } from '@pymodel/pi-tui'; -import chalk from 'chalk'; +import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +import { parseGlobOutput, parseGrepOutput, searchNoticeOnly } from './grep-output'; +import { outcomeRow, outcomeRows } from './outcome'; import { renderTruncated } from './truncated'; -import type { ResultRenderer } from './types'; +import { isSpilledToolOutput, type ResultRenderer } from './types'; -const GLANCE_SAMPLES = 3; +interface Glance { + readonly samples: string; + readonly moreCount: number; +} +// `'fallback'` hands the result to the generic renderer: a search the tool cut +// short before any row is only its notice, which beats an exact-looking +// empty glance. type GlanceFn = ( toolCall: Parameters[0], result: Parameters[1], -) => string; +) => Glance | null | 'fallback'; function withGlance(glance: GlanceFn | null): ResultRenderer { return (toolCall, result, ctx) => { - if (result.is_error) return renderTruncated(toolCall, result, ctx); + // A spilled result is the truncation envelope, not data: its first line + // tells the user the output was saved to a file. + if (result.is_error || isSpilledToolOutput(result.output)) { + return renderTruncated(toolCall, result, ctx); + } const out: Component[] = []; + // Collapsed: the glance is the card's outcome row — path samples in the + // flexible middle and the "+N more" count in the fixed tail, so a width + // cut drops samples, never the count. Expanded: one joined line above + // the raw output. if (glance !== null) { - const line = glance(toolCall, result); - if (line.length > 0) { - out.push(new Text(` ${chalk.dim(line)}`, 0, 0)); + const parts = glance(toolCall, result); + if (parts === 'fallback') return renderTruncated(toolCall, result, ctx); + if (parts !== null) { + const tail = parts.moreCount > 0 ? `, +${String(parts.moreCount)} more` : ''; + out.push( + ctx.expanded + ? new Text(` ${currentTheme.dim(`${parts.samples}${tail}`)}`, 0, 0) + : outcomeRow(OUTCOME_ROW_INDENT, parts.samples, tail), + ); } } if (ctx.expanded && result.output.length > 0) { - out.push(new Text(chalk.dim(result.output), 4, 0)); + out.push(new Text(currentTheme.dim(result.output), 4, 0)); } return out; }; } -function nonEmptyLines(text: string): string[] { - if (text.length === 0) return []; - return text.split('\n').filter((line) => line.length > 0); +function sampleList(labels: readonly string[], total = labels.length): Glance | null { + if (labels.length === 0) return null; + const samples = labels.slice(0, OUTCOME_GLANCE_SAMPLES); + return { samples: samples.join(', '), moreCount: total - samples.length }; } -// Strip a trailing `:line:col:text` so the glance shows the file path -// only, even when grep is in `content` mode (`src/foo.ts:42: foo()`). -function pathFromGrepLine(line: string): string { - const idx = line.indexOf(':'); - if (idx <= 0) return line; - const second = line.indexOf(':', idx + 1); - if (second <= 0) return line; - return line.slice(0, second); -} - -const grepGlance: GlanceFn = (_toolCall, result) => { - const lines = nonEmptyLines(result.output); - if (lines.length === 0) return ''; - const samples = lines.slice(0, GLANCE_SAMPLES).map(pathFromGrepLine); - const remaining = lines.length - samples.length; - const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; - return `${samples.join(', ')}${tail}`; +// Path samples in the shape the mode returns — `path`, `path:line` (the +// matched text is dropped), or `path:count` — with the tool's notices left +// out. A paginated result counts "+N more" against the tool-reported total, +// not just the page. +const grepGlance: GlanceFn = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return 'fallback'; + const stats = parseGrepOutput(toolCall, result.output); + const labels = stats.entries.map((entry) => entry.label); + return sampleList(labels, Math.max(labels.length, stats.total)); }; -const globGlance: GlanceFn = (_toolCall, result) => { - const lines = nonEmptyLines(result.output); - if (lines.length === 0) return ''; - const samples = lines.slice(0, GLANCE_SAMPLES); - const remaining = lines.length - samples.length; - const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; - return `${samples.join(', ')}${tail}`; +const globGlance: GlanceFn = (toolCall, result) => { + if (searchNoticeOnly(toolCall, result.output)) return 'fallback'; + return sampleList(parseGlobOutput(result.output).entries); }; // ── Exports ────────────────────────────────────────────────────────── @@ -83,8 +95,18 @@ export const readSummary: ResultRenderer = withGlance(null); export const fetchSummary: ResultRenderer = withGlance(null); export const webSearchSummary: ResultRenderer = withGlance(null); export const thinkSummary: ResultRenderer = withGlance(null); -export const editSummary: ResultRenderer = withGlance(null); -export const writeSummary: ResultRenderer = withGlance(null); + +// Edit and Write acknowledge success with one line the card already tells +// (`Replaced N occurrences in path`, `Wrote N bytes to path`): the header +// carries the path, the chip the size, and the call preview the change. Any +// other successful output (`No changes to make…`) is worth a row, shown the +// same way in both states so ctrl+o has nothing to add. +const FILE_CHANGE_ACK = /^(?:Replaced \d+ occurrences? in |Wrote|Appended)/; +export const fileChangeSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + if (FILE_CHANGE_ACK.test(result.output)) return []; + return outcomeRows(result.output, 'first'); +}; // Tools that benefit from inline path samples below the chip. export const grepSummary: ResultRenderer = withGlance(grepGlance); diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts index db8f4d654..c841d3cbb 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -31,6 +31,7 @@ export class TruncatedOutputComponent implements Component { private readonly indent: number; private readonly expandHint: boolean; private readonly tail: boolean; + private truncatedAtLastRender = false; constructor( output: string, @@ -76,13 +77,19 @@ export class TruncatedOutputComponent implements Component { return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); } + wasTruncated(): boolean { + return this.truncatedAtLastRender; + } + render(width: number): string[] { const contentLines = this.textComponent.render(width); if (this.expanded || contentLines.length <= this.maxLines) { + this.truncatedAtLastRender = false; return contentLines; } + this.truncatedAtLastRender = true; const remaining = contentLines.length - this.maxLines; if (this.tail) { const shown = contentLines.slice(contentLines.length - this.maxLines); diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts index cd14b5f1d..bb78eb36a 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts @@ -15,6 +15,10 @@ export type ResultRenderer = ( export const PREVIEW_LINES = RESULT_PREVIEW_LINES; +export function isSpilledToolOutput(output: string): boolean { + return output.startsWith('Tool output exceeded '); +} + export function strArg(args: Record, ...keys: string[]): string { for (const key of keys) { const v = args[key]; @@ -22,3 +26,11 @@ export function strArg(args: Record, ...keys: string[]): string } return ''; } + +const PER_LINE_SPILL_POINTER = '[Per-line truncation occurred;'; + +export function stripSpillPointer(output: string): string { + if (output.startsWith(PER_LINE_SPILL_POINTER)) return ''; + const at = output.indexOf(`\n${PER_LINE_SPILL_POINTER}`); + return at < 0 ? output : output.slice(0, at); +} diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/wait-for.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/wait-for.ts index a2931c443..bd3d70e78 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/wait-for.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/wait-for.ts @@ -122,7 +122,7 @@ function pluralizeTasks(count: number): string { return `${String(count)} background task${count === 1 ? '' : 's'}`; } -function parseWaitForOutput(output: string): WaitForResultView | undefined { +export function parseWaitForOutput(output: string): WaitForResultView | undefined { const status = field(output, 'wait_status'); if (status !== 'completed' && status !== 'timed_out' && status !== 'no_tasks') return undefined; const waitedMs = Number(field(output, 'waited_ms') ?? 0); diff --git a/apps/pythinker-code/src/tui/components/messages/truncated-header-line.ts b/apps/pythinker-code/src/tui/components/messages/truncated-header-line.ts new file mode 100644 index 000000000..ac4630445 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/truncated-header-line.ts @@ -0,0 +1,226 @@ +/** + * Single-row line shared by the tool card header, the Read group header and + * the collapsed card's outcome row. + * + * A header is either a plain string, truncated at the render width, or three + * segments: a fixed head (bullet + label), a flexible middle (command, update + * preview, key argument) and a fixed tail (the result chip). The middle gets + * whatever width is left after the head and the tail, so on a wide terminal + * it fills the row and on a narrow one the chip still survives. `keep` + * decides which end of the middle survives a cut: commands keep their start, + * paths keep their file name. + */ + +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; + +import { + ANSI_ESCAPE_PATTERN, + TAIL_WINDOW_UNITS_PER_CELL, + TRUNCATION_ELLIPSIS, +} from '#/tui/constant/rendering'; + +export interface HeaderFlex { + /** Plain text; `style` is applied after the cut so the ellipsis is styled too. */ + readonly text: string; + readonly style?: (text: string) => string; + readonly keep: 'head' | 'tail'; +} + +export interface HeaderSegments { + readonly head: string; + readonly flex: HeaderFlex; + readonly tail: string; +} + +export type HeaderContent = string | HeaderSegments; + +// The middle is plain text and gets styled after the cut, so it is cut by +// hand here: pi-tui's truncateToWidth wraps its ellipsis in a reset sequence, +// which would break the caller's styling around it. + +interface TextUnit { + readonly text: string; + readonly width: number; +} + +/** Grapheme clusters and whole escape sequences, in order; escape sequences measure zero width. */ +function* textUnits(text: string): Generator { + const segmenter = new Intl.Segmenter(); + let offset = 0; + for (const match of text.matchAll(ANSI_ESCAPE_PATTERN)) { + if (match.index > offset) { + for (const segment of segmenter.segment(text.slice(offset, match.index))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } + } + yield { text: match[0], width: 0 }; + offset = match.index + match[0].length; + } + for (const segment of segmenter.segment(text.slice(offset))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } +} + +/** Keep the start of `text` up to a trailing ellipsis, within `width` cells. */ +function keepHead(text: string, width: number): string { + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); + let out = ''; + let used = 0; + let truncated = false; + // Lazy iteration: only about one row of clusters is ever walked, so a huge + // argument (a base64 payload in an MCP call) costs nothing here. + for (const unit of textUnits(text)) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out += unit.text; + used += unit.width; + } + return truncated ? `${out}${TRUNCATION_ELLIPSIS}` : out; +} + +/** Keep the end of `text` behind a leading ellipsis, within `width` cells. */ +function keepTail(text: string, width: number): string { + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); + // The segmented slice stays bounded by the terminal width instead of the + // whole argument. ZWJ emoji and combining sequences pack many code units + // into one cell, so the window keeps TAIL_WINDOW_UNITS_PER_CELL per cell + // plus headroom for zero-width escape sequences; only sequences denser than + // that lose fitting clusters to the cut. + const window = budget * TAIL_WINDOW_UNITS_PER_CELL + 64; + const windowed = text.length > window ? text.slice(-window) : text; + const units = [...textUnits(windowed)]; + // The window edge may have split a grapheme or an escape sequence; drop + // whatever partial unit it left behind the leading ellipsis. + if (windowed.length < text.length) units.shift(); + let out = ''; + let used = 0; + let truncated = windowed.length < text.length; + for (const unit of units.toReversed()) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out = unit.text + out; + used += unit.width; + } + return truncated ? `${TRUNCATION_ELLIPSIS}${out}` : out; +} + +/** Whether `text` fits `width` cells, measured lazily so a huge argument is never walked whole. */ +function fits(text: string, width: number): boolean { + let used = 0; + for (const unit of textUnits(text)) { + used += unit.width; + if (used > width) return false; + } + return true; +} + +function fitFlex(flex: HeaderFlex, width: number): string { + if (fits(flex.text, width)) return flex.text; + return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); +} + +function layoutHeaderContent( + content: HeaderContent, + width: number, +): { line: string; truncated: boolean } { + const safeWidth = Math.max(1, width); + if (typeof content === 'string') { + return { + line: truncateToWidth(content, safeWidth, TRUNCATION_ELLIPSIS), + truncated: visibleWidth(content) > safeWidth, + }; + } + const { head, flex, tail } = content; + const style = flex.style ?? ((text: string) => text); + const available = safeWidth - visibleWidth(head) - visibleWidth(tail); + // Below two cells there is no room for even an ellipsis plus one character + // of the middle: drop the middle and keep the fixed parts, cutting the head + // from its end when even those overflow, so the tail (the result chip) + // stays visible whenever it can fit at all. + if (available < 2) { + const headWidth = visibleWidth(head); + const tailWidth = visibleWidth(tail); + if (headWidth + tailWidth <= safeWidth) { + const marker = + flex.text.length > 0 && safeWidth - headWidth - tailWidth >= 1 + ? style(TRUNCATION_ELLIPSIS) + : ''; + return { line: `${head}${marker}${tail}`, truncated: flex.text.length > 0 }; + } + if (safeWidth - tailWidth >= 2) { + // The head is already styled, so pi-tui's cutter (which resets styles + // around its ellipsis) is the right tool here. + return { + line: `${truncateToWidth(head, safeWidth - tailWidth, TRUNCATION_ELLIPSIS)}${tail}`, + truncated: true, + }; + } + return { + line: truncateToWidth(`${head}${tail}`, safeWidth, TRUNCATION_ELLIPSIS), + truncated: true, + }; + } + const fitted = fitFlex(flex, available); + return { line: `${head}${style(fitted)}${tail}`, truncated: fitted !== flex.text }; +} + +export function renderHeaderContent(content: HeaderContent, width: number): string { + return layoutHeaderContent(content, width).line; +} + +function sameContent(a: HeaderContent, b: HeaderContent): boolean { + if (typeof a === 'string' || typeof b === 'string') return a === b; + return ( + a.head === b.head && + a.tail === b.tail && + a.flex.text === b.flex.text && + a.flex.keep === b.flex.keep && + a.flex.style === b.flex.style + ); +} + +export class TruncatedHeaderLine implements Component { + // The card and the gutter container reuse a child's output by array + // identity, so an unchanged header must hand back the same array — a fresh + // one per frame would defeat both caches on every paint. + private cache: + | { content: HeaderContent; width: number; lines: string[]; truncated: boolean } + | undefined; + + constructor(private content: HeaderContent) {} + + setText(content: HeaderContent): void { + if (sameContent(this.content, content)) return; + this.content = content; + this.cache = undefined; + } + + invalidate(): void { + this.cache = undefined; + } + + /** + * Whether the last render cut any part of the row — an outcome row cut to + * the terminal width hides the remainder of a long line, which ctrl+o + * reveals wrapped. Drives the footer's ctrl+o hint. + */ + wasTruncated(): boolean { + return this.cache?.truncated ?? false; + } + + render(width: number): string[] { + const cache = this.cache; + if (cache !== undefined && cache.content === this.content && cache.width === width) { + return cache.lines; + } + const { line, truncated } = layoutHeaderContent(this.content, width); + const lines = [line]; + this.cache = { content: this.content, width, lines, truncated }; + return lines; + } +} diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 93367cd9e..87a09b7f7 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -23,6 +23,27 @@ export const SHELL_OUTPUT_PREVIEW_LINES = 10; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +// The ellipsis marking a single-row line (card header, outcome row) that was +// cut to the terminal width or that stands in for hidden output lines. +export const TRUNCATION_ELLIPSIS = '…'; +// ANSI escape sequences (CSI, OSC) — tool output can carry them — that a +// width-aware cut must treat as zero-width atomic units: never counted toward +// the budget, never split in half. +export const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; +// Code units a single terminal cell may hold before a tail-preserving cut's +// window can no longer see it: a ZWJ family emoji is about eleven per two +// cells, and combining sequences run longer. +export const TAIL_WINDOW_UNITS_PER_CELL = 16; +// Left indent of a collapsed tool card's outcome rows, aligning them with +// the message-body indent. +export const OUTCOME_ROW_INDENT = ' '; +// Non-empty output lines a collapsed tool card shows in full before it falls +// back to one telling outcome row. +export const OUTCOME_MAX_LINES = 3; +// Path samples a collapsed Grep/Glob card lists in its glance row before +// counting the rest as "+N more". +export const OUTCOME_GLANCE_SAMPLES = 3; + // Cap on the step-retry detail line under the waiting spinner, so huge // provider error bodies (occasionally whole HTML error pages) can't flood // the activity pane. diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index b185cfa40..9583acbc9 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -138,7 +138,7 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { hasDispose, isExpandable } from './utils/component-capabilities'; +import { hasDispose, hasHiddenContent, isExpandable, isExpandedComponent } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; @@ -180,6 +180,7 @@ import { TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_WINDOW_ENABLED, + expandCutoffIndex, groupTurns, turnsToTrim, } from './utils/transcript-window'; @@ -410,6 +411,7 @@ export class PythinkerTUI { this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); + this.state.footer.setExpandHintProvider(() => this.toolOutputExpandHint()); this.uninstallRainbowHatch = installRainbowHatch(() => { this.state.ui.requestRender(); }); @@ -3253,24 +3255,34 @@ export class PythinkerTUI { ); } + private toolOutputExpandHint(): 'expand' | 'collapse' | null { + const children = this.state.transcriptContainer.children; + if (this.state.toolOutputExpanded) { + for (const child of children) { + if (isExpandedComponent(child) && hasHiddenContent(child)) return 'collapse'; + } + return null; + } + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + const expandCutoff = expandCutoffIndex(children.length, boundaries, TRANSCRIPT_EXPAND_TURNS); + for (let i = expandCutoff; i < children.length; i++) { + if (hasHiddenContent(children[i])) return 'expand'; + } + return null; + } + toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; const children = this.state.transcriptContainer.children; - // A component is expandable only if it sits at or after the start of the - // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most - // recent `expandTurns` turns. Position-based so it also covers streaming - // components that have no entry in the metadata map. const boundaries: number[] = []; for (let i = 0; i < children.length; i++) { if (isTurnBoundaryComponent(children[i]!)) boundaries.push(i); } - const expandCutoff = - TRANSCRIPT_EXPAND_TURNS <= 0 - ? children.length - : boundaries.length > TRANSCRIPT_EXPAND_TURNS - ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! - : 0; + const expandCutoff = expandCutoffIndex(children.length, boundaries, TRANSCRIPT_EXPAND_TURNS); for (let i = 0; i < children.length; i++) { const child = children[i]!; diff --git a/apps/pythinker-code/src/tui/utils/component-capabilities.ts b/apps/pythinker-code/src/tui/utils/component-capabilities.ts index 5b4f81356..6f2f9d189 100644 --- a/apps/pythinker-code/src/tui/utils/component-capabilities.ts +++ b/apps/pythinker-code/src/tui/utils/component-capabilities.ts @@ -2,6 +2,11 @@ export interface Expandable { setExpanded(expanded: boolean): void; } +export interface HidesContent extends Expandable { + hasHiddenContent(): boolean; + isExpanded(): boolean; +} + export interface Disposable { dispose(): void; } @@ -15,6 +20,24 @@ export function isExpandable(obj: unknown): obj is Expandable { ); } +export function hasHiddenContent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'hasHiddenContent' in obj && + typeof (obj as HidesContent).hasHiddenContent === 'function' && + (obj as HidesContent).hasHiddenContent() + ); +} + +export function isExpandedComponent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'isExpanded' in obj && + typeof (obj as HidesContent).isExpanded === 'function' && + (obj as HidesContent).isExpanded() + ); +} + export function hasDispose(value: unknown): value is Disposable { return ( typeof value === 'object' && diff --git a/apps/pythinker-code/src/tui/utils/transcript-window.ts b/apps/pythinker-code/src/tui/utils/transcript-window.ts index 3c623fecc..5391be696 100644 --- a/apps/pythinker-code/src/tui/utils/transcript-window.ts +++ b/apps/pythinker-code/src/tui/utils/transcript-window.ts @@ -123,3 +123,12 @@ export function turnsToTrim( } return toRemove; } + +export function expandCutoffIndex( + childCount: number, + boundaries: readonly number[], + expandTurns: number, +): number { + if (expandTurns <= 0) return childCount; + return boundaries.length > expandTurns ? boundaries[boundaries.length - expandTurns]! : 0; +} diff --git a/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts index 4d65c46a8..092b98ae7 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -125,7 +125,7 @@ describe('AgentActivityViewer', () => { { id: 't1', name: 'Grep', - args: { pattern: 'IEventBus' }, + args: { pattern: 'IEventBus', output_mode: 'content' }, status: 'done', startedAt: 0, result: { @@ -143,7 +143,7 @@ describe('AgentActivityViewer', () => { const text = renderPlain(viewer); expect(text).toContain('── step 0 ──'); expect(text).toContain('Looking for the event bus definition.'); - expect(text).toContain('Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('Used Grep (IEventBus) · 2 matches across 2 files'); // grep glance renderer: path samples below the header (`path:line` form) expect(text).toContain('src/a.ts:1, src/b.ts:2'); }); @@ -173,11 +173,12 @@ describe('AgentActivityViewer', () => { const collapsed = makeViewer({ record: makeRecord() }); const collapsedText = renderPlain(collapsed); - expect(collapsedText).toContain('ctrl+o to expand'); - expect(collapsedText).not.toContain('line 10'); + expect(collapsedText).toContain('line 10'); + expect(collapsedText).not.toContain('line 1\n'); collapsed.handleInput(CTRL_O); const expandedText = renderPlain(collapsed); + expect(expandedText).toContain('line 1'); expect(expandedText).toContain('line 10'); }); @@ -246,7 +247,7 @@ describe('formatSubagentActivityPreview', () => { { id: 't1', name: 'Grep', - args: { pattern: 'IEventBus' }, + args: { pattern: 'IEventBus', output_mode: 'content' }, status: 'done', startedAt: 0, result: { @@ -270,7 +271,7 @@ describe('formatSubagentActivityPreview', () => { ); expect(text).toContain('── step 0 ──'); expect(text).toContain('Looking around.'); - expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches across 2 files'); expect(text).toContain('● Using Read (/repo/src/a.ts)'); expect(text).toContain('│ reading…'); // live tail for the in-flight call expect(text).toContain('Result:'); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index 0a108a056..7a939b796 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -2,7 +2,7 @@ import { visibleWidth, type TUI } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { extractKeyArgument, extractKeyArgumentDetail, ToolCallComponent } from '#/tui/components/messages/tool-call'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -26,6 +26,18 @@ function stubTui(rows: number): TUI { } as unknown as TUI; } +describe('extractKeyArgumentDetail', () => { + it('keeps a long single-line Bash command untruncated for the width-aware header', () => { + const command = `echo ${'x'.repeat(80)}`; + const truncated = extractKeyArgument('Bash', { command }); + const detail = extractKeyArgumentDetail('Bash', { command }); + expect(truncated).not.toBe(command); + expect(truncated?.endsWith('…')).toBe(true); + expect(detail?.text).toBe(command); + expect(detail?.keep).toBe('head'); + }); +}); + describe('ToolCallComponent', () => { afterEach(() => { vi.useRealTimers(); @@ -261,11 +273,9 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('line1'); - expect(collapsed).toContain('line2'); - expect(collapsed).toContain('line3'); - expect(collapsed).not.toContain('line4'); - expect(collapsed).toContain('… (2 more lines, ctrl+o to expand)'); + expect(collapsed).toContain('line5'); + expect(collapsed).not.toContain('line1'); + expect(collapsed).toContain('4 more lines'); component.setExpanded(true); @@ -290,8 +300,8 @@ describe('ToolCallComponent', () => { const out = strip(component.render(100).join('\n')); expect(out).toContain('Running a command'); - expect(out).toContain('line1'); expect(out).toContain('line2'); + expect(out).not.toContain('line1'); }); it('uses a Unicode ellipsis when truncating live Bash output', () => { @@ -349,7 +359,6 @@ describe('ToolCallComponent', () => { const collapsed = strip(component.render(100).join('\n')); expect(collapsed).toContain('Running a command'); expect(collapsed).toContain('echo step1'); - expect(collapsed).toContain('echo step10'); expect(collapsed).not.toContain('echo step11'); component.setExpanded(true); @@ -359,25 +368,19 @@ describe('ToolCallComponent', () => { expect(expanded).toContain('echo step15'); }); - it('keeps the command preview after the result lands to avoid a height collapse', () => { + it('keeps the first command line in the header after the result lands', () => { const component = new ToolCallComponent( { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, undefined, ); - // Sanity: while running, the in-flight preview shows the command. expect(strip(component.render(100).join('\n'))).toContain('$ echo step1'); component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); - // Collapsed result view still shows the command preview (capped at - // COMMAND_PREVIEW_LINES) so a multi-line command with short output does - // not collapse the card. The command is owned by buildCallPreview, so it - // must appear exactly once — the result renderer no longer renders it. const out = strip(component.render(100).join('\n')); expect(out).toContain('Ran a command'); expect(out).toContain('$ echo step1'); - expect(out).toContain('echo step10'); expect(out).not.toContain('echo step11'); expect(out).toContain('done'); expect(out.split('$ echo step1').length - 1).toBe(1); @@ -388,19 +391,16 @@ describe('ToolCallComponent', () => { expect(expanded).toContain('echo step15'); }); - it('keeps the command preview when the command produces no output', () => { + it('keeps the first command line in the header when the command produces no output', () => { const component = new ToolCallComponent( { id: 'call_bash_empty', name: 'Bash', args: { command: 'mkdir -p a/b/c\necho done' } }, { tool_call_id: 'call_bash_empty', output: '', is_error: false }, ); - // buildContent early-returns on empty output, but the command preview - // (owned by buildCallPreview) must still render so the card does not - // collapse to just the header. const out = strip(component.render(100).join('\n')); expect(out).toContain('Ran a command'); expect(out).toContain('$ mkdir -p a/b/c'); - expect(out).toContain('echo done'); + expect(out).not.toContain('echo done'); }); }); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts index bfee04922..3ee19196b 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -25,8 +25,12 @@ function chipFor(name: string, args: Record, out: ToolResultBlo } describe('chip registry', () => { - it('Bash has no chip (exit code is not surfaced)', () => { - expect(pickChip('Bash')).toBeUndefined(); + it('Bash chip is silent when output fits the outcome rows', () => { + expect(chipFor('Bash', { command: 'echo hi' }, result('hi'))).toBe(''); + }); + + it('Bash chip counts hidden lines past the outcome cap', () => { + expect(chipFor('Bash', { command: 'seq' }, result('a\nb\nc\nd'))).toBe('3 more lines'); }); it('Edit chip shows +N -M from args diff', () => { @@ -53,8 +57,8 @@ describe('chip registry', () => { expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo'))).toBe('1 line'); }); - it('Grep chip shows match count', () => { - expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 matches'); + it('Grep chip counts files in the default files-with-matches mode', () => { + expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 files'); }); it('Grep chip says "no matches" on empty result', () => { @@ -78,12 +82,17 @@ describe('chip registry', () => { it.each([ 'No more matches at offset=347 in the current result set (347 matches).', 'No matches collected; search incomplete.', - 'No non-sensitive matches found (3 sensitive file(s) filtered).', 'No matches found', ])('does not count an empty Glob page as a file: %s', (output) => { expect(chipFor('Glob', {}, result(output))).toBe('no files'); }); + it('leaves the chip off a Glob result that is only a sensitive-file notice', () => { + expect( + chipFor('Glob', {}, result('No non-sensitive matches found (3 sensitive file(s) filtered).')), + ).toBe(''); + }); + it('ignores Glob footers on a complete page', () => { expect( chipFor('Glob', {}, result('a.ts\nb.ts\nFiltered 2 sensitive file(s).')), @@ -103,13 +112,13 @@ describe('chip registry', () => { expect(chipFor('Glob', {}, result(output))).toBe('2+ files'); }); - it('reports no files when a multi-line warning precedes an empty page', () => { + it('leaves the chip off when a Glob warning precedes an empty page', () => { const output = [ 'Glob completed with warnings; some directories could not be read: rg: /deep/a: Permission denied', 'rg: /deep/b: Permission denied', 'No more matches at offset=9 in the collected partial result set (4 matches).', ].join('\n'); - expect(chipFor('Glob', {}, result(output))).toBe('no files'); + expect(chipFor('Glob', {}, result(output))).toBe(''); }); it('distinguishes the last Glob page from a partial result set', () => { @@ -127,7 +136,7 @@ describe('chip registry', () => { expect( chipFor('Glob', {}, result('Showing matches.ts\nContinue with.txt\nNo more matches.ts')), ).toBe('3 files'); - expect(chipFor('Grep', {}, result('Showing matches 1\u20132 of 3.'))).toBe('1 match'); + expect(chipFor('Grep', {}, result('Showing matches 1\u20132 of 3.'))).toBe('no matches'); }); it('FetchURL chip shows size and is non-empty', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts index 9edafb94f..e978eddfa 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -68,11 +68,11 @@ describe('tool-result registry', () => { expect(out).toContain('… (2 more lines, ctrl+o to expand)'); }); - it('uses truncated renderer for Bash to preserve raw output UX', () => { + it('shows the last Bash output line when collapsed past the outcome cap', () => { const renderer = pickResultRenderer('Bash'); const out = strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx))); - expect(out).toContain('one'); - expect(out).toContain('… (1 more lines, ctrl+o to expand)'); + expect(out).toContain('four'); + expect(out).not.toContain('one'); }); it('Read renders no body when collapsed (header chip carries the count)', () => { @@ -115,7 +115,7 @@ describe('tool-result registry', () => { const out = strip( joinRender( renderer( - call('Grep', { pattern: 'foo' }), + call('Grep', { pattern: 'foo', output_mode: 'content' }), result('src/a.ts:42: foo()\nsrc/b.ts:7:foo'), ctx, ), diff --git a/apps/pythinker-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/pythinker-code/test/tui/components/messages/truncated-header-line.test.ts new file mode 100644 index 000000000..76a4717ec --- /dev/null +++ b/apps/pythinker-code/test/tui/components/messages/truncated-header-line.test.ts @@ -0,0 +1,159 @@ +import { visibleWidth } from '@pymodel/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { + renderHeaderContent, + TruncatedHeaderLine, + type HeaderSegments, +} from '#/tui/components/messages/truncated-header-line'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const upper = (text: string): string => text.toUpperCase(); + +function segments(text: string, keep: 'head' | 'tail', tail = ' · 3 lines'): HeaderSegments { + return { head: '● Ran a command · $ ', flex: { text, keep }, tail }; +} + +describe('renderHeaderContent', () => { + it('truncates a plain string at the width', () => { + expect(strip(renderHeaderContent('short', 40))).toBe('short'); + const cut = strip(renderHeaderContent('x'.repeat(50), 20)); + expect(visibleWidth(cut)).toBeLessThanOrEqual(20); + expect(cut.endsWith('…')).toBe(true); + }); + + it('lets the middle fill the row and keeps the tail when it fits', () => { + const line = strip(renderHeaderContent(segments('git status --short', 'head'), 80)); + expect(line).toBe('● Ran a command · $ git status --short · 3 lines'); + }); + + it('cuts the middle from its end and still shows the tail on a narrow row', () => { + const command = + 'git log --oneline -5 origin/main -- apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts'; + const line = strip(renderHeaderContent(segments(command, 'head'), 60)); + expect(visibleWidth(line)).toBeLessThanOrEqual(60); + expect(line.startsWith('● Ran a command · $ git log')).toBe(true); + expect(line.endsWith('… · 3 lines')).toBe(true); + }); + + it('keeps the end of a path-like middle behind a leading ellipsis', () => { + const path = + '/Users/someone/.pythinker-code/sessions/session_5b2c/agents/main/tasks/bash-4g77gs5f/output.log'; + const line = strip( + renderHeaderContent( + { head: '● Used Read (', flex: { text: path, keep: 'tail' }, tail: ') · 8 lines' }, + 60, + ), + ); + expect(visibleWidth(line)).toBeLessThanOrEqual(60); + expect(line).toContain('(…'); + expect(line.endsWith('/output.log) · 8 lines')).toBe(true); + }); + + it('measures wide characters by cells, not by code units', () => { + const line = strip( + renderHeaderContent(segments('运行全部测试并生成覆盖率报告然后上传', 'head', ''), 30), + ); + expect(visibleWidth(line)).toBeLessThanOrEqual(30); + expect(line.endsWith('…')).toBe(true); + }); + + it('styles the middle after the cut so the ellipsis is styled too', () => { + const line = renderHeaderContent( + { head: 'H ', flex: { text: 'abcdefghij', keep: 'head', style: upper }, tail: ' T' }, + 10, + ); + expect(line).toBe('H ABCDE… T'); + }); + + it('drops the middle before the fixed parts when the row is too narrow for it', () => { + const content = { head: 'HEAD ', flex: { text: 'abcdef', keep: 'head' as const }, tail: ' T' }; + // One spare cell: the middle collapses to an ellipsis between the fixed parts. + expect(renderHeaderContent(content, 8)).toBe('HEAD … T'); + // No spare cell: the middle is dropped outright, both fixed parts stay. + expect(renderHeaderContent(content, 7)).toBe('HEAD T'); + }); + + it('cuts the head from its end so the tail survives when even the fixed parts overflow', () => { + const content = { head: 'HEAD ', flex: { text: 'abcdef', keep: 'head' as const }, tail: ' T' }; + const line = strip(renderHeaderContent(content, 5)); + expect(line).toBe('HE… T'); + // Below two cells for the head there is nothing left to keep: cut from the end. + const tiny = strip(renderHeaderContent(content, 3)); + expect(visibleWidth(tiny)).toBeLessThanOrEqual(3); + expect(tiny.endsWith('…')).toBe(true); + }); + + it('keeps ANSI escape sequences atomic and zero-width when cutting', () => { + const colored = '\x1b[32mabcdef\x1b[0mghijkl'; + // 2 (head) + 5 for the middle: the whole opening sequence plus 4 visible + // cells, then the ellipsis. The sequence is never split or measured. + const line = renderHeaderContent( + { head: 'H ', flex: { text: colored, keep: 'head' }, tail: '' }, + 7, + ); + expect(line).toBe('H \x1b[32mabcd…'); + expect(visibleWidth(line)).toBeLessThanOrEqual(7); + }); + + it('cuts a huge argument without walking it whole', () => { + const huge = `prefix-${'x'.repeat(200_000)}-suffix`; + const head = strip(renderHeaderContent(segments(huge, 'head', ''), 40)); + expect(head.startsWith('● Ran a command · $ prefix-xxx')).toBe(true); + expect(head.endsWith('…')).toBe(true); + expect(visibleWidth(head)).toBeLessThanOrEqual(40); + + const tail = strip(renderHeaderContent(segments(huge, 'tail', ''), 40)); + expect(tail).toContain('$ …'); + expect(tail.endsWith('-suffix')).toBe(true); + expect(visibleWidth(tail)).toBeLessThanOrEqual(40); + }); +}); + +describe('TruncatedHeaderLine', () => { + it('reuses its rendered array across structurally equal headers', () => { + const line = new TruncatedHeaderLine(segments('ls', 'head')); + const first = line.render(80); + line.setText(segments('ls', 'head')); + expect(line.render(80)).toBe(first); + line.setText(segments('ls -la', 'head')); + expect(line.render(80)).not.toBe(first); + }); + + it('reports whether the last render cut the row', () => { + const command = + 'git log --oneline -5 origin/main -- apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts'; + const line = new TruncatedHeaderLine(segments(command, 'head')); + expect(line.wasTruncated()).toBe(false); + line.render(160); + expect(line.wasTruncated()).toBe(false); + line.render(60); + expect(line.wasTruncated()).toBe(true); + line.render(160); + expect(line.wasTruncated()).toBe(false); + }); +}); + +describe('graphemes that pack many code units into a cell', () => { + // A ZWJ family emoji: 2 cells, 11 UTF-16 code units. + const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'; + + it('never assumes a cut from code-unit length alone', () => { + const text = family.repeat(10); + expect(visibleWidth(text)).toBe(20); + const line = renderHeaderContent({ head: '', flex: { text, keep: 'head' }, tail: '' }, 20); + expect(line).toBe(text); + const tailKept = renderHeaderContent({ head: '', flex: { text, keep: 'tail' }, tail: '' }, 20); + expect(tailKept).toBe(text); + }); + + it('keeps whole emoji clusters at the tail when it does have to cut', () => { + const text = `${'x'.repeat(30)}${family.repeat(5)}`; + const line = renderHeaderContent({ head: '', flex: { text, keep: 'tail' }, tail: '' }, 9); + expect(line).toBe(`…${family.repeat(4)}`); + expect(visibleWidth(line)).toBe(9); + }); +}); diff --git a/apps/pythinker-code/test/tui/tasks-browser.test.ts b/apps/pythinker-code/test/tui/tasks-browser.test.ts index 262c29295..406e643ae 100644 --- a/apps/pythinker-code/test/tui/tasks-browser.test.ts +++ b/apps/pythinker-code/test/tui/tasks-browser.test.ts @@ -771,7 +771,7 @@ describe('TasksBrowserController — opening an agent task', () => { turnId: 1, toolCallId: 't1', name: 'Grep', - args: { pattern: 'foo' }, + args: { pattern: 'foo', output_mode: 'content' }, } as Event); store.applyEvent({ sessionId: 's1', @@ -789,7 +789,7 @@ describe('TasksBrowserController — opening an agent task', () => { const browser = state.tasksBrowser as { tailOutput?: string }; expect(browser.tailOutput).toContain('── step 0 ──'); - expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches'); + expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches across 2 files'); controller.close(); }); });