diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 72003776aa..993e34133a 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -223,6 +223,121 @@ describe('Maka Pi TUI transcript', () => { ), /ctx 20k\/500k 4%/, ); + test('status line drops whole low-value segments on overflow, lowest rank first (#3421)', () => { + const richMeta = { + ...meta(), + modelContextWindow: 500_000, + usage: { + costUsd: 0.42, + cacheHitInput: 60, + cacheMissInput: 40, + contextRemaining: 480_000, + }, + }; + // Wide: everything renders. + const wide = stripAnsi(renderMakaPiStatusLine(richMeta, 120)); + assert.match(wide, /ctx 20k\/500k 4%/); + assert.match(wide, /\$0\.42/); + assert.match(wide, /cache 60%/); + assert.match(wide, /deepseek · \/tmp\/project/); + + // Below full width, cache drops before cost, and no segment is cut + // mid-token while any lower rank still survives. + const fullWidth = visibleWidth(wide); + const noCache = stripAnsi(renderMakaPiStatusLine(richMeta, fullWidth - 1)); + assert.doesNotMatch(noCache, /cache/); + assert.match(noCache, /\$0\.42/); + const noCost = stripAnsi( + renderMakaPiStatusLine(richMeta, fullWidth - 'cache 60% · '.length - 1), + ); + assert.doesNotMatch(noCost, /cache|\$0\.42/); + assert.match(noCost, /deepseek · \/tmp\/project/); + }); + + test('status line shortens cwd to its basename before dropping it (#3421)', () => { + const line = stripAnsi( + renderMakaPiStatusLine( + { + ...meta(), + cwd: '/very/long/nested/project-directory', + modelContextWindow: 500_000, + usage: { + costUsd: 0, + cacheHitInput: 1, + cacheMissInput: 1, + contextRemaining: 480_000, + }, + }, + // Room for title, mode, model, ctx and a short tail only. + 'Maka · Auto · deepseek-v4-flash · ctx 20k/500k 4% · project-directory'.length, + ), + ); + assert.doesNotMatch(line, /very\/long/); + assert.match(line, /project-directory/); + }); + + test('status line drops a drive-root cwd instead of rendering an empty basename (#3421)', () => { + const line = stripAnsi( + renderMakaPiStatusLine( + { + ...meta(), + cwd: 'C:\\', + modelContextWindow: 500_000, + usage: { + costUsd: 0.5, + cacheHitInput: 1, + cacheMissInput: 1, + contextRemaining: 480_000, + }, + }, + 40, + ), + ); + // C:\ has no useful basename; the segment drops cleanly rather than + // leaving an empty segment dangling after the separator. + assert.doesNotMatch(line, /C:\\/); + assert.doesNotMatch(line, /·\s*$/); + }); + + test('status line never drops mode, model, goal, or ctx at narrow widths (#3421)', () => { + const line = stripAnsi( + renderMakaPiStatusLine( + { + ...meta(), + permissionMode: 'bypass', + modelContextWindow: 500_000, + usage: { + costUsd: 9.99, + cacheHitInput: 1, + cacheMissInput: 1, + contextRemaining: 480_000, + }, + goal: { + goalId: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'Ship it', + setAt: Date.now() - 60_000, + iterations: 1, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: null, + tokensSpent: 0, + lastReason: null, + achievedAt: null, + pausedAt: null, + status: 'active' as const, + }, + }, + 75, + ), + ); + assert.match(line, /Full access/); + assert.match(line, /deepseek-v4-flash/); + assert.match(line, /goal 1\/50/); + assert.match(line, /ctx 20k\/500k 4%/); + assert.doesNotMatch(line, /\$9\.99|cache|deepseek ·|tmp\/project/); }); test('keeps assistant text after a tool call visible after the tool block', () => { diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 930513d668..6958736584 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3358,7 +3358,9 @@ describe('Maka Pi TUI runner', () => { }); test('restores switched session state from stored messages', async () => { - const terminal = new FakeTerminal(); + // 120 cols: the status line fits every segment, so the usage segments this + // test asserts (ctx, cache) are not priority-dropped (#3421). + const terminal = new FakeTerminal(120); const driver = new SlashCommandDriver( [fakeSessionSummary('session-2', '/repo')], new Map([ diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 87433d8e0e..6cb849cc76 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -17,6 +17,7 @@ * under the License. */ +(feat(cli): responsive priority-based status line for narrow terminals (#3421)) import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; import type { ProviderRetryEvent, @@ -27,7 +28,6 @@ import type { ToolResultContent, } from '@maka/core/events'; import { - deriveTurnRecords, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, type SystemNoteMessage, @@ -38,19 +38,21 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; import { mergeShellRunStateWithDiagnostics } from '@maka/core/shell-run-result'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; -import { - type ToolActivityStatus, - toolResultActivityStatus, - unfinishedToolActivityStatus, -} from '@maka/core/tool-result-status'; import { type ShellRunUpdate } from '@maka/core/events'; import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import { + materializeSession, + type ChatItem, + type ToolActivityItem, +} from '@maka/runtime/materializer'; import type { MakaSessionDriver } from './session-driver.js'; import { BoundedChunkBuffer } from './bounded-chunk-buffer.js'; import { ansi } from './tui-ansi.js'; import { fitLine, formatTokenCount, + formatToolResultContent, formatUnknown, limitText, markdownTheme, @@ -95,6 +97,14 @@ export interface MakaPiTranscriptState { * entryInLiveViewport. */ renderGeometry: MakaPiRenderGeometry; + /** + * Ref polls folded at `tool_start`, childToolUseId → card facts. A Read / + * StopBackgroundTask aimed at a ref a visible Bash card owns is internal + * polling: it never renders a row, and its result folds straight into the + * parent. The facts survive only so an errored poll can surface as a normal + * card instead of being swallowed. + */ + pendingShellRunPolls: Map; /** Aggregated token usage for statusline display; reset on session switch. */ usage: MakaPiUsageSummary; /** @@ -136,6 +146,13 @@ export interface MakaPiRenderGeometry { viewportTop: number; } +/** Facts kept from a folded poll's `tool_start` so an errored result can still materialize a proper card. */ +export interface MakaPiPendingShellRunPoll { + toolName: string; + title?: string; + input: unknown; +} + /** A single live output chunk from a `tool_output_delta` event. */ export interface MakaPiToolOutputDelta { seq: number; @@ -155,27 +172,31 @@ export type MakaPiTranscriptEntry = | { kind: 'thinking'; messageId: string; text: string; expanded: boolean } | { kind: 'tool'; - /** Present for live events so durable hydration is turn-scoped. */ + /** Present for live events so terminal reconciliation is turn-scoped. */ turnId?: string; toolUseId: string; toolName: string; title?: string; input: unknown; - /** Structured result returned by the tool. */ + /** Structured result; preferred over `output` when present. */ result?: ToolResultContent; + /** Flattened result text, kept as a fallback for text/json/unknown kinds. */ + output?: string; /** In-memory revision for render-cache invalidation when a result is replaced. */ resultVersion: number; progress: BoundedChunkBuffer; outputDeltas: BoundedChunkBuffer; durationMs?: number; - /** Invocation lifecycle. Resource liveness remains authoritative in `result`. */ - callStatus: ToolActivityStatus; - /** Ownership of an active ShellRun; absent means locally owned. */ - shellRunSource?: 'source_owned' | 'unavailable'; + status: 'running' | 'done' | 'error' | 'failed' | 'aborted' | 'detached' | 'unavailable'; /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ expanded: boolean; - /** An internal shell-run poll retained for correlation but not displayed. */ - suppressed?: boolean; + /** + * Set when a successful shell-run poll is folded into its parent while + * off-screen: the entry cannot be spliced (that would shift line numbers + * and clear scrollback), but it must not render as an independent card + * on a future full redraw. A hidden entry contributes zero lines. + */ + hidden?: boolean; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -213,6 +234,7 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { expandAllTools: false, expandAllThinking: false, renderGeometry: { entryFirstLine: undefined, viewportTop: 0 }, + pendingShellRunPolls: new Map(), usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, steering: [], followup: [], @@ -260,11 +282,7 @@ export function refreshRunningShellRunElapsed( ): boolean { let found = false; for (const entry of state.entries) { - if ( - entry.kind !== 'tool' || - entry.result?.kind !== 'shell_run' || - makaPiToolPresentationStatus(entry) !== 'running' - ) + if (entry.kind !== 'tool' || entry.status !== 'running' || entry.result?.kind !== 'shell_run') continue; entry.durationMs = Math.max(0, now - entry.result.startedAt); found = true; @@ -297,18 +315,17 @@ export function applyShellRunViewUpdateToTranscript( tool.toolName !== 'Bash' || tool.result?.kind !== 'shell_run' || tool.result.ref !== update.result.ref || - tool.result.revision !== update.result.revision || !isActiveShellRunStatus(tool.result.status) ) return applied; - const shellRunSource = + const status = update.ownership.kind === 'local' - ? undefined + ? 'running' : update.ownership.kind === 'source_owned' - ? 'source_owned' + ? 'detached' : 'unavailable'; - if (tool.shellRunSource === shellRunSource) return applied; - tool.shellRunSource = shellRunSource; + if (tool.status === status) return applied; + tool.status = status; return true; } @@ -327,8 +344,10 @@ export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], ): void { - state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + const view = materializeSession(messages); + state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); clearPendingInteractions(state); + state.pendingShellRunPolls.clear(); state.expandAllTools = false; state.expandAllThinking = false; // The old entries are gone; no position is known until the next render, and @@ -353,53 +372,53 @@ export function replaceTranscriptWithStoredMessages( * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. */ -export function hydrateToolsWithStoredMessages( +export function reconcileToolsWithStoredMessages( state: MakaPiTranscriptState, turnId: string, messages: readonly StoredMessage[], ): boolean { const turnMessages = messages.filter((message) => message.turnId === turnId); const durableTools = new Map( - foldStoredShellRunChildren(storedMessagesToTranscriptEntries(turnMessages)) + foldStoredShellRunChildren( + materializeSession(turnMessages).items.flatMap(chatItemToTranscriptEntries), + ) .filter( (entry): entry is Extract => entry.kind === 'tool', ) .map((entry) => [entry.toolUseId, entry]), ); let changed = false; + const reconciled: MakaPiTranscriptEntry[] = []; for (const entry of state.entries) { - if (entry.kind !== 'tool' || entry.turnId !== turnId) continue; + if (entry.kind !== 'tool' || entry.turnId !== turnId) { + reconciled.push(entry); + continue; + } const durable = durableTools.get(entry.toolUseId); - if (!durable) continue; + if (!durable) { + if (!entryInLiveViewport(state, entry)) { + entry.hidden = true; + reconciled.push(entry); + } + changed = true; + continue; + } entry.toolName = durable.toolName; entry.title = durable.title; entry.input = structuredClone(durable.input); - entry.callStatus = mergeToolCallStatus(entry.callStatus, durable.callStatus); - if ( - durable.result?.kind === 'shell_run' && - durable.callStatus !== 'errored' && - entry.toolName === 'Bash' - ) { - applyShellRunResult(entry, structuredClone(durable.result)); - } else if (durable.result !== undefined && entry.result === undefined) { - entry.result = structuredClone(durable.result); - entry.resultVersion += 1; - if (durable.durationMs !== undefined) entry.durationMs = durable.durationMs; - } else if (durable.durationMs !== undefined && entry.durationMs === undefined) { - entry.durationMs = durable.durationMs; - } + entry.result = durable.result ? structuredClone(durable.result) : undefined; + entry.output = durable.output; + entry.durationMs = durable.durationMs; + entry.status = durable.status; + entry.hidden = durable.hidden; + entry.resultVersion += 1; changed = true; + reconciled.push(entry); } + state.entries = reconciled; return changed; } -function mergeToolCallStatus( - current: ToolActivityStatus, - durable: ToolActivityStatus, -): ToolActivityStatus { - return current === 'running' ? durable : current; -} - /** * True when the entry will render inside the live viewport, or has not been * rendered yet (a fresh entry first appears at the tail, inside the viewport). @@ -492,24 +511,21 @@ export async function submitCompactToTranscript(input: { driver: Pick; onChange?: () => void; }): Promise { - let outcome: Extract['contextCompactionOutcome']; + let completed = false; + let sawCompactionNotice = false; try { for await (const event of input.driver.compactSession()) { - if (event.type === 'complete') outcome = event.contextCompactionOutcome; - if (event.type === 'token_usage') accumulateUsage(input.state.usage, event); - else applyMakaSessionEventToTranscript(input.state, event); + if (event.type === 'token_usage' && contextBudgetOutcomeNotice(event.contextBudget)) + sawCompactionNotice = true; + if (event.type === 'complete' && event.stopReason === 'end_turn') completed = true; + applyMakaSessionEventToTranscript(input.state, event); input.onChange?.(); } - if (outcome) { + if (completed && !sawCompactionNotice) { input.state.entries.push({ kind: 'notice', - level: outcome.kind === 'failed' ? 'error' : 'info', - text: - outcome.kind === 'compacted' - ? 'Context compacted.' - : outcome.kind === 'unchanged' - ? 'Nothing to compact.' - : `Context compaction failed: ${outcome.reason}.`, + level: 'info', + text: 'Nothing to compact.', }); input.onChange?.(); } @@ -565,11 +581,17 @@ export function applyMakaSessionEventToTranscript( // folds into the parent at tool_result. A poll is folded only when its // parent card already carries the run's shell_run result — otherwise it // renders normally and the tool_result fold below still applies. - const ref = event.shellRunRef ?? readArgsRef(event.args); - const suppressed = - (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && - !!ref && - !!findShellRunParent(state, ref, event.toolUseId); + if (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') { + const ref = readArgsRef(event.args); + if (ref && findShellRunParent(state, ref, event.toolUseId)) { + state.pendingShellRunPolls.set(event.toolUseId, { + toolName: event.toolName, + ...(event.displayName ? { title: event.displayName } : {}), + input: projectToolActivityArgs(event.toolName, event.args), + }); + break; + } + } state.entries.push({ kind: 'tool', turnId: event.turnId, @@ -580,19 +602,49 @@ export function applyMakaSessionEventToTranscript( resultVersion: 0, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - callStatus: 'running', + status: 'running', expanded: state.expandAllTools, - ...(suppressed ? { suppressed: true } : {}), }); break; } case 'tool_result': { - const tool = findToolEntry(state, event.toolUseId); - if (tool?.suppressed && event.contentOmitted && !event.isError) { - state.entries.splice(state.entries.indexOf(tool), 1); + const foldedPoll = state.pendingShellRunPolls.get(event.toolUseId); + if (foldedPoll) { + state.pendingShellRunPolls.delete(event.toolUseId); + const shellRun = event.content.kind === 'shell_run' ? event.content : undefined; + const parent = shellRun + ? findShellRunParent(state, shellRun.ref, event.toolUseId) + : undefined; + // isError is the call-level authoritative status: a failed call never + // folds, even when it carries a well-formed shell_run payload. + if (parent && shellRun && !event.isError) { + applyLiveShellRunResultToParent(state, parent, shellRun); + break; + } + // The poll failed (or lost its parent): surface a normal card so the + // failure is never swallowed by the fold. + const entry: MakaPiToolEntry = { + kind: 'tool', + turnId: event.turnId, + toolUseId: event.toolUseId, + toolName: foldedPoll.toolName, + ...(foldedPoll.title ? { title: foldedPoll.title } : {}), + input: foldedPoll.input, + progress: createProgressBuffer(), + outputDeltas: createOutputBuffer(), + result: event.content, + output: formatToolResultContent(event.content), + resultVersion: 1, + durationMs: event.durationMs, + status: event.isError ? 'error' : 'done', + expanded: state.expandAllTools, + }; + if (shellRun && !event.isError) applyOwnShellRunResult(entry, shellRun, event.durationMs); + state.entries.push(entry); break; } + const tool = findToolEntry(state, event.toolUseId); const shellRun = event.content.kind === 'shell_run' ? event.content : undefined; const parent = shellRun ? findShellRunParent(state, shellRun.ref, event.toolUseId) @@ -600,29 +652,39 @@ export function applyMakaSessionEventToTranscript( if (tool && parent && shellRun && !event.isError) { applyLiveShellRunResultToParent(state, parent, shellRun); if (tool.toolName === 'Read' || tool.toolName === 'StopBackgroundTask') { - state.entries.splice(state.entries.indexOf(tool), 1); + // Splicing an off-screen entry shifts subsequent entries' line + // numbers, which changes the composed buffer above the viewport and + // forces a scrollback-clearing full redraw (#1135). Leave it in + // place but mark it hidden so it contributes zero lines: a future + // full redraw (width change, session switch) will not render it as + // a duplicate card. The stale entry is fully cleaned on the next + // session switch / replaceTranscriptWithStoredMessages. + if (entryInLiveViewport(state, tool)) { + state.entries.splice(state.entries.indexOf(tool), 1); + } else { + tool.hidden = true; + } } else { applyOwnShellRunResult(tool, shellRun, event.durationMs); } break; } if (tool) { - if (tool.suppressed) unsuppressToolAtTail(state, tool); - tool.callStatus = toolResultActivityStatus(event.isError, event.content); if (shellRun) { if (tool.toolName === 'Bash') { applyShellRunResult(tool, shellRun); } else { applyOwnShellRunResult(tool, shellRun, event.durationMs); } + // isError is the call-level authoritative status: a failed call shows + // error even when its payload is a well-formed (still running) run. + if (event.isError) tool.status = 'error'; } else { - if (!(event.contentOmitted && tool.result?.kind === 'shell_run')) { - tool.durationMs = event.durationMs; - } - if (!event.contentOmitted) { - tool.result = event.content; - tool.resultVersion += 1; - } + tool.status = toolResultTranscriptStatus(event.content, event.isError); + tool.result = event.content; + tool.output = formatToolResultContent(event.content); + tool.durationMs = event.durationMs; + tool.resultVersion += 1; } } else { state.entries.push({ @@ -633,10 +695,11 @@ export function applyMakaSessionEventToTranscript( input: undefined, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - ...(!event.contentOmitted ? { result: event.content } : {}), - resultVersion: event.contentOmitted ? 0 : 1, + result: event.content, + output: formatToolResultContent(event.content), + resultVersion: 1, durationMs: event.durationMs, - callStatus: toolResultActivityStatus(event.isError, event.content), + status: toolResultTranscriptStatus(event.content, event.isError), expanded: state.expandAllTools, }); } @@ -737,7 +800,7 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); - dropSuppressedTools(state); + state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'error', @@ -747,7 +810,7 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); - dropSuppressedTools(state); + state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'info', @@ -758,7 +821,6 @@ export function applyMakaSessionEventToTranscript( case 'complete': // The turn is over; any unresolved interaction is no longer actionable. clearPendingInteractions(state); - dropSuppressedTools(state); if (event.stopReason === 'max_tokens') { state.entries.push({ kind: 'notice', @@ -773,101 +835,77 @@ export function applyMakaSessionEventToTranscript( } } -function storedMessagesToTranscriptEntries( - messages: readonly StoredMessage[], -): MakaPiTranscriptEntry[] { - const entries: MakaPiTranscriptEntry[] = []; - const resultsByToolUseId = new Map( - messages - .filter( - (message): message is Extract => - message.type === 'tool_result', - ) - .map((message) => [message.toolUseId, message]), - ); - const turnStatusById = new Map( - deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), - ); - - for (const message of messages) { - switch (message.type) { - case 'user': - entries.push({ +function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { + switch (item.kind) { + case 'user': + return [ + { kind: - message.origin?.kind === 'legacy_automation' + item.message.origin?.kind === 'legacy_automation' ? 'legacy_automation' - : message.origin?.kind === 'goal' + : item.message.origin?.kind === 'goal' ? 'goal_continuation' : 'user', - text: message.displayText ?? message.text, + text: item.message.displayText ?? item.message.text, + }, + ]; + case 'assistant': { + const entries: MakaPiTranscriptEntry[] = []; + // Stored thinking happened before the reply text, so it resumes above it. + const thinking = item.message.thinking?.text; + if (thinking?.trim()) { + // Replay resets the expansion defaults to collapsed, so replayed + // entries start collapsed too. + entries.push({ + kind: 'thinking', + messageId: item.message.id, + text: thinking, + expanded: false, }); - break; - case 'assistant': { - // Stored thinking happened before the reply text, so it resumes above it. - const thinking = message.thinking?.text; - if (thinking?.trim()) { - entries.push({ - kind: 'thinking', - messageId: message.id, - text: thinking, - expanded: false, - }); - } - entries.push({ kind: 'assistant', messageId: message.id, text: message.text }); - break; } - case 'tool_call': - entries.push( - storedToolToTranscriptEntry( - message, - resultsByToolUseId.get(message.id), - turnStatusById.get(message.turnId), - ), - ); - break; - case 'system_note': { - const entry = systemNoteToTranscriptEntry(message); - if (entry) entries.push(entry); - break; - } - case 'tool_result': - case 'permission_decision': - case 'token_usage': - case 'turn_state': - break; + entries.push({ kind: 'assistant', messageId: item.message.id, text: item.message.text }); + return entries; + } + case 'tool': + return [toolActivityToTranscriptEntry(item.item)]; + case 'system_note': { + const entry = systemNoteToTranscriptEntry(item.message); + return entry ? [entry] : []; } } - return entries; } -function storedToolToTranscriptEntry( - call: Extract, - result: Extract | undefined, - turnStatus: ReturnType[number]['status'] | undefined, -): MakaPiToolEntry { +function toolActivityToTranscriptEntry(item: ToolActivityItem): MakaPiToolEntry { + const output = item.result + ? formatToolResultContent(item.result) + : item.status === 'interrupted' + ? 'Interrupted before the tool returned a result.' + : undefined; const entry: MakaPiToolEntry = { kind: 'tool', - toolUseId: call.id, - toolName: call.toolName, - ...(call.displayName ? { title: call.displayName } : {}), - input: projectToolActivityArgs(call.toolName, call.args), + toolUseId: item.toolUseId, + toolName: item.toolName, + ...(item.displayName ? { title: item.displayName } : {}), + input: item.args, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - ...(result ? { result: result.content } : {}), - resultVersion: result ? 1 : 0, - ...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}), - callStatus: result - ? toolResultActivityStatus(result.isError, result.content) - : unfinishedToolActivityStatus(turnStatus), + ...(item.result ? { result: item.result } : {}), + ...(output ? { output } : {}), + resultVersion: item.result ? 1 : 0, + ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}), + status: transcriptToolStatus(item.status), expanded: false, }; + if (item.result?.kind === 'subagent') { + entry.status = subagentTranscriptStatus(item.result.status); + } // A failed call keeps its error status and raw payload: applying the shell_run // as the card's own result would let a still-running or settled payload // overwrite the error and swallow the failure on replay. This mirrors the live // tool_result path, which forces `error` for any errored shell_run result, and // is what lets the stored fold below recognize an errored poll by its status. - if (result?.content.kind === 'shell_run' && !result.isError) - applyOwnShellRunResult(entry, result.content); + if (item.result?.kind === 'shell_run' && !item.isError) + applyOwnShellRunResult(entry, item.result); return entry; } @@ -877,11 +915,7 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra // An errored poll never folds: its failed payload must not mutate the parent // and its error card must survive replay, mirroring the live path's "failure // is never swallowed" invariant. - if ( - entry.kind === 'tool' && - entry.result?.kind === 'shell_run' && - entry.callStatus !== 'errored' - ) { + if (entry.kind === 'tool' && entry.result?.kind === 'shell_run' && entry.status !== 'error') { const shellRun = entry.result; const parent = [...folded] .reverse() @@ -902,66 +936,63 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra return folded; } -export type MakaPiToolPresentationStatus = - | 'running' - | 'done' - | 'error' - | 'failed' - | 'aborted' - | 'detached' - | 'unavailable'; - -export function makaPiToolPresentationStatus(entry: MakaPiToolEntry): MakaPiToolPresentationStatus { - if (entry.result?.kind === 'subagent') return SUBAGENT_PRESENTATION_STATUS[entry.result.status]; - if (entry.result?.kind === 'shell_run') { - if (entry.callStatus === 'errored') return 'error'; - if (entry.toolName === 'WriteStdin') { - return entry.result.operation?.kind === 'pty_control' && entry.result.operation.failed - ? 'error' - : 'done'; - } - if (isActiveShellRunStatus(entry.result.status)) { - return entry.shellRunSource === 'source_owned' - ? 'detached' - : entry.shellRunSource === 'unavailable' - ? 'unavailable' - : 'running'; - } - return SHELL_RUN_PRESENTATION_STATUS[entry.result.status]; +function transcriptToolStatus(status: ToolActivityItem['status']): MakaPiToolEntry['status'] { + switch (status) { + case 'completed': + return 'done'; + case 'errored': + case 'interrupted': + return 'error'; + case 'pending': + case 'running': + return 'running'; } - return CALL_PRESENTATION_STATUS[entry.callStatus]; -} - -const CALL_PRESENTATION_STATUS = { - running: 'running', - completed: 'done', - errored: 'error', - interrupted: 'aborted', -} as const satisfies Record; - -const SUBAGENT_PRESENTATION_STATUS = { - completed: 'done', - failed: 'failed', - cancelled: 'aborted', - running: 'running', - waiting_for_user: 'running', -} as const satisfies Record< - Extract['status'], - MakaPiToolPresentationStatus ->; - -const SHELL_RUN_PRESENTATION_STATUS = { - starting: 'running', - running: 'running', - completed: 'done', - cancelled: 'aborted', - failed: 'failed', - timed_out: 'failed', - orphaned: 'failed', -} as const satisfies Record< - Extract['status'], - MakaPiToolPresentationStatus ->; +} + +function toolResultTranscriptStatus( + result: ToolResultContent, + isError: boolean, +): MakaPiToolEntry['status'] { + return result.kind === 'subagent' + ? subagentTranscriptStatus(result.status) + : isError + ? 'error' + : 'done'; +} + +function subagentTranscriptStatus( + status: Extract['status'], +): MakaPiToolEntry['status'] { + switch (status) { + case 'completed': + return 'done'; + case 'failed': + return 'failed'; + case 'cancelled': + return 'aborted'; + case 'running': + case 'waiting_for_user': + return 'running'; + } +} + +function shellRunTranscriptStatus( + status: Extract['status'], +): MakaPiToolEntry['status'] { + switch (status) { + case 'starting': + case 'running': + return 'running'; + case 'completed': + return 'done'; + case 'cancelled': + return 'aborted'; + case 'failed': + case 'timed_out': + case 'orphaned': + return 'failed'; + } +} function applyShellRunResult( entry: MakaPiToolEntry, @@ -970,7 +1001,9 @@ function applyShellRunResult( const current = entry.result?.kind === 'shell_run' ? entry.result : undefined; const merged = mergeShellRunStateWithDiagnostics(current, result, 'cli.transcript'); if (!merged.changed) return false; + entry.status = shellRunTranscriptStatus(merged.result.status); entry.result = merged.result; + entry.output = formatToolResultContent(merged.result); entry.durationMs = Math.max( 0, (merged.result.completedAt ?? merged.result.updatedAt) - merged.result.startedAt, @@ -984,7 +1017,14 @@ function applyOwnShellRunResult( result: Extract, operationDurationMs = entry.durationMs, ): void { + entry.status = + entry.toolName === 'WriteStdin' + ? result.operation?.kind === 'pty_control' && result.operation.failed + ? 'error' + : 'done' + : shellRunTranscriptStatus(result.status); entry.result = result; + entry.output = formatToolResultContent(result); if (entry.toolName === 'WriteStdin') { entry.durationMs = operationDurationMs; } else { @@ -1022,11 +1062,15 @@ function contextBudgetNoticeText( (candidate) => candidate.decision === 'replaced', ); if (!contextBudget || !decision) return undefined; - const kind = decision.boundaryKind ?? 'context'; - const coveredTurns = decision.coveredTurns; - const coveredEvents = decision.coveredRuntimeEvents; + const kind = decision.boundaryKind ?? contextBudget.highWaterReason ?? 'context'; + const coveredTurns = decision.coveredTurns ?? contextBudget.historyCompactedTurns; + const coveredEvents = decision.coveredRuntimeEvents ?? contextBudget.historyCompactedEvents; const savedTokens = decision.estimatedTokensSaved ?? + tokenDelta( + contextBudget.historyCompactedEstimatedTokensBefore, + contextBudget.historyCompactedEstimatedTokensAfter, + ) ?? tokenDelta(contextBudget.estimatedTokensBefore, contextBudget.estimatedTokensAfter); const parts = [`Context compacted: ${kind}`]; if (coveredTurns !== undefined || coveredEvents !== undefined) { @@ -1092,19 +1136,19 @@ export function renderMakaPiTranscript( const entryFirstLine = new Map(); const viewportTop = state.renderGeometry.viewportTop; - let previousVisibleEntry: MakaPiTranscriptEntry | undefined; for (let i = 0; i < state.entries.length; i += 1) { const entry = state.entries[i]!; - if (entry.kind === 'tool' && entry.suppressed) { + if (entry.kind === 'tool' && entry.hidden) { entryFirstLine.set(entry, lines.length); continue; } + const prev = state.entries[i - 1]; // A blank gap separates human-facing boundaries (user/assistant/thinking/ // notice) and the edges of a tool stack; only consecutive tool entries (the // agent-work stack) have no blank line between them. Thinking reads as // model output, so it gets the same blank-line breathing room as assistant // text rather than packing against the tool rows. - const continuesStack = entry.kind === 'tool' && previousVisibleEntry?.kind === 'tool'; + const continuesStack = entry.kind === 'tool' && prev?.kind === 'tool'; if (!continuesStack) lines.push(''); entryFirstLine.set(entry, lines.length); // An entry that sits entirely above the live viewport is in terminal @@ -1121,7 +1165,6 @@ export function renderMakaPiTranscript( lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); - previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; @@ -1185,10 +1228,6 @@ function clearPendingInteractions(state: MakaPiTranscriptState): void { state.queuedInteractions = []; } -function dropSuppressedTools(state: MakaPiTranscriptState): void { - state.entries = state.entries.filter((entry) => entry.kind !== 'tool' || !entry.suppressed); -} - /** * Per-entry render cache. The transcript re-renders on every keystroke and * stream delta, but only the tail entry actually changes; caching the rendered @@ -1294,15 +1333,17 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): case 'notice': return `notice|${width}|${entry.level}|${entry.text.length}`; case 'tool': - // A tool entry mutates in place as it runs: its derived presentation and - // duration change, progress/output deltas append, and resultVersion - // advances whenever durable detail or a resource revision is accepted. - // Count those facts instead of duplicating the result rendering contract. + // A tool entry mutates in place as it runs: status/duration flip, + // progress/output deltas append, and resultVersion advances whenever a + // result is accepted. Count those revisions instead of duplicating the + // result's rendering contract in this cache key. `input` and + // `toolName` are omitted deliberately: both are set once at `tool_start`, + // before the first render, and never change, so they can't go stale. return [ 'tool', width, entry.expanded ? 1 : 0, - makaPiToolPresentationStatus(entry), + entry.status, entry.durationMs ?? '', entry.title ?? entry.toolName, entry.progress.version, @@ -1328,20 +1369,25 @@ export function permissionModeLabel(mode: string): string { export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width: number): string { const safeWidth = Math.max(1, width); const sep = ansi.dim(' · '); - const parts: string[] = [ - ansi.bold(metadata.title), - ansi.dim(permissionModeLabel(metadata.permissionMode)), - ansi.dim(metadata.model), + // #3421: segments carry a dropRank so overflow drops whole low-value + // segments instead of cutting the chain mid-token from the right. + // Lower ranks drop first; segments without a rank never drop: + // title, permission mode and goal are safety-relevant, ctx is the + // context budget, model is the session's identity. + const parts: MakaPiStatusLineSegment[] = [ + { text: ansi.bold(metadata.title) }, + { text: ansi.dim(permissionModeLabel(metadata.permissionMode)) }, + { text: ansi.dim(metadata.model) }, ]; // #1064: omit thinking:default — it is noise before the user explicitly // changes the level. Only a non-default, explicitly set level shows. if (metadata.thinkingLevel) { - parts.push(ansi.dim(`thinking:${metadata.thinkingLevel}`)); + parts.push({ text: ansi.dim(`thinking:${metadata.thinkingLevel}`), dropRank: 3 }); } if (metadata.orchestrationMode === 'swarm') { - parts.push(ansi.accent('swarm')); + parts.push({ text: ansi.accent('swarm'), dropRank: 4 }); } else if (metadata.orchestrationMode === 'graph') { - parts.push(ansi.accent('graph')); + parts.push({ text: ansi.accent('graph'), dropRank: 4 }); } // An autonomous goal burns tokens between prompts; it must never be // invisible. Terminal goals show nothing (the desktop chip hides them too). @@ -1350,13 +1396,14 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width // paused gets warning salience: the loop stopped burning but stays armed // and resumable, which the user must not miss. waiting is a normal // transient between turns, so it stays dim like the other chrome. - parts.push( - metadata.goal.status === 'active' - ? ansi.accent(text) - : metadata.goal.status === 'paused' - ? ansi.yellow(text) - : ansi.dim(text), - ); + parts.push({ + text: + metadata.goal.status === 'active' + ? ansi.accent(text) + : metadata.goal.status === 'paused' + ? ansi.yellow(text) + : ansi.dim(text), + }); } const usage = metadata.usage; // ctx segment: only show "used" when contextRemaining is available, since @@ -1383,18 +1430,68 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width } if (usage) { if (usage.costUsd > 0) { - parts.push(ansi.dim(`$${formatCost(usage.costUsd)}`)); + parts.push({ text: ansi.dim(`$${formatCost(usage.costUsd)}`), dropRank: 1 }); } const totalCache = usage.cacheHitInput + usage.cacheMissInput; if (totalCache > 0) { const hitRate = Math.round((usage.cacheHitInput / totalCache) * 100); - parts.push(ansi.dim(`cache ${hitRate}%`)); + parts.push({ text: ansi.dim(`cache ${hitRate}%`), dropRank: 0 }); } } - parts.push(ansi.dim(metadata.connectionSlug)); + parts.push({ text: ansi.dim(metadata.connectionSlug), dropRank: 2 }); // #1064: shorten cwd to ~-relative path instead of the full path. - parts.push(ansi.dim(shortenCwd(metadata.cwd))); - return fitLine(parts.join(sep), safeWidth); + const cwd = shortenCwd(metadata.cwd); + // cwd degrades progressively (full → basename → dropped), after every + // ranked segment above but before the final truncation fallback. A drive + // root (C:\) or filesystem root has no useful basename — empty, or the + // path itself — so it drops directly instead of rendering an empty + // segment after the separator. + const cwdBase = basename(cwd); + parts.push({ + text: ansi.dim(cwd), + dropRank: 5, + shortenedText: cwdBase === '' || cwdBase === cwd ? undefined : ansi.dim(cwdBase), + }); + return fitStatusLine(parts, sep, safeWidth); +} + +interface MakaPiStatusLineSegment { + text: string; + /** Overflow drops whole segments lowest-rank-first; undefined never drops. */ + dropRank?: number; + /** Progressive fallback tried before this segment is dropped entirely. */ + shortenedText?: string; +} + +function fitStatusLine(segments: MakaPiStatusLineSegment[], sep: string, width: number): string { + const lineWidth = (segs: MakaPiStatusLineSegment[]): number => + visibleWidth(segs.map((segment) => segment.text).join(sep)); + let kept = segments; + // Drop whole low-value segments, lowest rank first, re-checking after each + // rank so the fewest possible segments are sacrificed. + for (let rank = 0; lineWidth(kept) > width; rank++) { + const droppable = kept.some((segment) => segment.dropRank !== undefined); + if (!droppable) break; + const lowest = Math.min( + ...kept.flatMap((segment) => (segment.dropRank !== undefined ? [segment.dropRank] : [])), + ); + // A segment with a shortened form degrades to it before dropping. + const shorten = kept.find( + (segment) => segment.dropRank === lowest && segment.shortenedText !== undefined, + ); + if (shorten) { + kept = kept.map((segment) => + segment === shorten + ? { ...segment, text: segment.shortenedText ?? segment.text, shortenedText: undefined } + : segment, + ); + } else { + kept = kept.filter((segment) => segment.dropRank !== lowest); + } + } + // Last resort for still-oversized lines (e.g. a long model id alone): + // the previous hard truncation. + return fitLine(kept.map((segment) => segment.text).join(sep), width); } /** @@ -1581,14 +1678,6 @@ function findToolEntry( ); } -function unsuppressToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntry): void { - tool.suppressed = undefined; - const index = state.entries.indexOf(tool); - if (index < 0 || index === state.entries.length - 1) return; - state.entries.splice(index, 1); - state.entries.push(tool); -} - function createProgressBuffer(): BoundedChunkBuffer { return new BoundedChunkBuffer({ maxChars: LIVE_TOOL_BUFFER_MAX_CHARS,