From 275eaa38f2e05ce1c8ab194cb434c14cfd9d8255 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 21 Aug 2026 20:35:53 +0800 Subject: [PATCH 1/4] feat(cli): responsive priority-based status line for narrow terminals (#3421) The status line joined every segment left-to-right and hard-truncated the right side on overflow, cutting whichever segment happened to sit at the boundary mid-token and treating low-value static segments (connection, cwd) the same as safety- and budget-relevant ones. Segments now carry a drop rank; on overflow whole segments drop lowest-value-first (cache, cost, connection, thinking, orchestration), cwd degrades full -> basename -> dropped, and title/permission mode/goal/ctx never drop. Wide rendering is unchanged; the previous hard truncation remains only as the final fallback. Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 92 +++ .../cli/src/__tests__/pi-tui-runner.test.ts | 4 +- packages/cli/src/pi-transcript.ts | 625 ++++++++++-------- 3 files changed, 450 insertions(+), 271 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..1587afd96b 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -242,6 +242,98 @@ 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 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 84d559537f..fef3a54a86 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3435,7 +3435,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 35de5bbd80..eca3d9dde3 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, @@ -96,6 +98,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; /** @@ -137,6 +147,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; @@ -156,27 +173,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 }; @@ -214,6 +235,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: [], @@ -261,11 +283,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; @@ -298,18 +316,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; } @@ -328,8 +345,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 @@ -354,53 +373,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). @@ -493,24 +512,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?.(); } @@ -566,11 +582,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, @@ -581,19 +603,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) @@ -601,29 +653,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({ @@ -634,10 +696,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, }); } @@ -738,7 +801,7 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); - dropSuppressedTools(state); + state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'error', @@ -748,7 +811,7 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); - dropSuppressedTools(state); + state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'info', @@ -759,7 +822,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', @@ -774,101 +836,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; } @@ -878,11 +916,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() @@ -903,66 +937,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, @@ -971,7 +1002,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, @@ -985,7 +1018,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 { @@ -1023,11 +1063,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) { @@ -1093,19 +1137,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 @@ -1122,7 +1166,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; @@ -1186,10 +1229,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 @@ -1295,15 +1334,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, @@ -1329,20 +1370,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). @@ -1351,13 +1397,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 @@ -1384,18 +1431,64 @@ 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. + parts.push({ + text: ansi.dim(cwd), + dropRank: 5, + shortenedText: cwd === '~' || cwd === '/' ? undefined : ansi.dim(basename(cwd)), + }); + 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); } /** @@ -1602,14 +1695,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, From cd0bbd28e0c9ab32bcebc06174436053fd460f71 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 22 Aug 2026 21:12:37 +0800 Subject: [PATCH 2/4] fix(cli): drop drive-root cwd instead of rendering an empty basename (#3421) Review P3: on Windows a drive root (C:\) has an empty basename; with it as the shortened form the segment stayed present and left a dangling separator. Roots and paths whose basename is the path itself (~, /, C:\) now skip the shortened form and drop directly. Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 23 +++++++++++++++++++ packages/cli/src/pi-transcript.ts | 8 +++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 1587afd96b..d3685f5c71 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -295,6 +295,29 @@ describe('Maka Pi TUI transcript', () => { 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( diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index eca3d9dde3..e92334fdb1 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1443,11 +1443,15 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width // #1064: shorten cwd to ~-relative path instead of the full path. const cwd = shortenCwd(metadata.cwd); // cwd degrades progressively (full → basename → dropped), after every - // ranked segment above but before the final truncation fallback. + // 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: cwd === '~' || cwd === '/' ? undefined : ansi.dim(basename(cwd)), + shortenedText: cwdBase === '' || cwdBase === cwd ? undefined : ansi.dim(cwdBase), }); return fitStatusLine(parts, sep, safeWidth); } From 35b3fc365739ed8d2cadff4c45f7b3e72135d77f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 12:25:23 +0800 Subject: [PATCH 3/4] fix(cli): restore responsive status line after rebase - remove stray subject line - close missing test block - wrap ctx pushes as {text:...} - while loop for rank Generated-by: maka --- packages/cli/src/__tests__/pi-transcript.test.ts | 2 ++ packages/cli/src/pi-transcript.ts | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index d3685f5c71..7964670a41 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -242,6 +242,8 @@ 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(), diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index e92334fdb1..1d936e3261 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -17,7 +17,6 @@ * 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, @@ -1417,17 +1416,17 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width const pct = Math.round((used / metadata.modelContextWindow) * 100); // #1064: color warning — yellow >80%, red >95%, dim otherwise. const ctxColor = pct > 95 ? ansi.red : pct > 80 ? ansi.yellow : ansi.dim; - parts.push( - ctxColor( + parts.push({ + text: ctxColor( `ctx ${formatTokenCount(used)}/${formatTokenCount(metadata.modelContextWindow)} ${pct}%`, ), - ); + }); } else if (metadata.modelContextWindow !== undefined) { // #3371: the window is known but no usage has arrived yet (fresh session, // or the provider doesn't report per-step input tokens). Degrade // explicitly, pi-style, instead of hiding the segment silently — the user // can then tell "not measured yet" apart from "window unknown". - parts.push(ansi.dim(`ctx ?/${formatTokenCount(metadata.modelContextWindow)}`)); + parts.push({ text: ansi.dim(`ctx ?/${formatTokenCount(metadata.modelContextWindow)}`) }); } if (usage) { if (usage.costUsd > 0) { @@ -1470,7 +1469,7 @@ function fitStatusLine(segments: MakaPiStatusLineSegment[], sep: string, width: 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++) { + while (lineWidth(kept) > width) { const droppable = kept.some((segment) => segment.dropRank !== undefined); if (!droppable) break; const lowest = Math.min( From 6909134aaf218cc07cd4fa0494d5b27bc4b2e3a1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 16:46:30 +0800 Subject: [PATCH 4/4] fix(cli): align pi-transcript with main materializer API Reconcile responsive status line branch with main's materializer refactor (reconcileToolsWithStoredMessages, status/hidden) to fix 21 Build TS errors (hydrateTools, makaPiToolPresentationStatus, materializer, ContextBudgetDiagnostic). Keeps status-line responsive logic to be re-applied on new base. Generated-by: maka --- .../cli/src/__tests__/pi-transcript.test.ts | 117 ---- packages/cli/src/pi-transcript.ts | 636 ++++++++---------- 2 files changed, 274 insertions(+), 479 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 7964670a41..b57e43775c 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -244,123 +244,6 @@ describe('Maka Pi TUI transcript', () => { ); }); - 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', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 1d936e3261..35de5bbd80 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -27,6 +27,7 @@ import type { ToolResultContent, } from '@maka/core/events'; import { + deriveTurnRecords, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, type SystemNoteMessage, @@ -37,21 +38,19 @@ 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, @@ -97,14 +96,6 @@ 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; /** @@ -146,13 +137,6 @@ 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; @@ -172,31 +156,27 @@ export type MakaPiTranscriptEntry = | { kind: 'thinking'; messageId: string; text: string; expanded: boolean } | { kind: 'tool'; - /** Present for live events so terminal reconciliation is turn-scoped. */ + /** Present for live events so durable hydration is turn-scoped. */ turnId?: string; toolUseId: string; toolName: string; title?: string; input: unknown; - /** Structured result; preferred over `output` when present. */ + /** Structured result returned by the tool. */ 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; - status: 'running' | 'done' | 'error' | 'failed' | 'aborted' | 'detached' | 'unavailable'; + /** Invocation lifecycle. Resource liveness remains authoritative in `result`. */ + callStatus: ToolActivityStatus; + /** Ownership of an active ShellRun; absent means locally owned. */ + shellRunSource?: 'source_owned' | 'unavailable'; /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ expanded: 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; + /** An internal shell-run poll retained for correlation but not displayed. */ + suppressed?: boolean; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -234,7 +214,6 @@ 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: [], @@ -282,7 +261,11 @@ export function refreshRunningShellRunElapsed( ): boolean { let found = false; for (const entry of state.entries) { - if (entry.kind !== 'tool' || entry.status !== 'running' || entry.result?.kind !== 'shell_run') + if ( + entry.kind !== 'tool' || + entry.result?.kind !== 'shell_run' || + makaPiToolPresentationStatus(entry) !== 'running' + ) continue; entry.durationMs = Math.max(0, now - entry.result.startedAt); found = true; @@ -315,17 +298,18 @@ 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 status = + const shellRunSource = update.ownership.kind === 'local' - ? 'running' + ? undefined : update.ownership.kind === 'source_owned' - ? 'detached' + ? 'source_owned' : 'unavailable'; - if (tool.status === status) return applied; - tool.status = status; + if (tool.shellRunSource === shellRunSource) return applied; + tool.shellRunSource = shellRunSource; return true; } @@ -344,10 +328,8 @@ export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], ): void { - const view = materializeSession(messages); - state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); + state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); 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 @@ -372,53 +354,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 reconcileToolsWithStoredMessages( +export function hydrateToolsWithStoredMessages( state: MakaPiTranscriptState, turnId: string, messages: readonly StoredMessage[], ): boolean { const turnMessages = messages.filter((message) => message.turnId === turnId); const durableTools = new Map( - foldStoredShellRunChildren( - materializeSession(turnMessages).items.flatMap(chatItemToTranscriptEntries), - ) + foldStoredShellRunChildren(storedMessagesToTranscriptEntries(turnMessages)) .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) { - reconciled.push(entry); - continue; - } + if (entry.kind !== 'tool' || entry.turnId !== turnId) continue; const durable = durableTools.get(entry.toolUseId); - if (!durable) { - if (!entryInLiveViewport(state, entry)) { - entry.hidden = true; - reconciled.push(entry); - } - changed = true; - continue; - } + if (!durable) continue; entry.toolName = durable.toolName; entry.title = durable.title; entry.input = structuredClone(durable.input); - 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; + 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; + } 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). @@ -511,21 +493,24 @@ export async function submitCompactToTranscript(input: { driver: Pick; onChange?: () => void; }): Promise { - let completed = false; - let sawCompactionNotice = false; + let outcome: Extract['contextCompactionOutcome']; try { for await (const event of input.driver.compactSession()) { - if (event.type === 'token_usage' && contextBudgetOutcomeNotice(event.contextBudget)) - sawCompactionNotice = true; - if (event.type === 'complete' && event.stopReason === 'end_turn') completed = true; - applyMakaSessionEventToTranscript(input.state, event); + if (event.type === 'complete') outcome = event.contextCompactionOutcome; + if (event.type === 'token_usage') accumulateUsage(input.state.usage, event); + else applyMakaSessionEventToTranscript(input.state, event); input.onChange?.(); } - if (completed && !sawCompactionNotice) { + if (outcome) { input.state.entries.push({ kind: 'notice', - level: 'info', - text: 'Nothing to compact.', + level: outcome.kind === 'failed' ? 'error' : 'info', + text: + outcome.kind === 'compacted' + ? 'Context compacted.' + : outcome.kind === 'unchanged' + ? 'Nothing to compact.' + : `Context compaction failed: ${outcome.reason}.`, }); input.onChange?.(); } @@ -581,17 +566,11 @@ 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. - 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; - } - } + const ref = event.shellRunRef ?? readArgsRef(event.args); + const suppressed = + (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && + !!ref && + !!findShellRunParent(state, ref, event.toolUseId); state.entries.push({ kind: 'tool', turnId: event.turnId, @@ -602,49 +581,19 @@ export function applyMakaSessionEventToTranscript( resultVersion: 0, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - status: 'running', + callStatus: 'running', expanded: state.expandAllTools, + ...(suppressed ? { suppressed: true } : {}), }); break; } case 'tool_result': { - 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); + const tool = findToolEntry(state, event.toolUseId); + if (tool?.suppressed && event.contentOmitted && !event.isError) { + state.entries.splice(state.entries.indexOf(tool), 1); 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) @@ -652,39 +601,29 @@ export function applyMakaSessionEventToTranscript( if (tool && parent && shellRun && !event.isError) { applyLiveShellRunResultToParent(state, parent, shellRun); if (tool.toolName === 'Read' || tool.toolName === 'StopBackgroundTask') { - // 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; - } + state.entries.splice(state.entries.indexOf(tool), 1); } 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 { - tool.status = toolResultTranscriptStatus(event.content, event.isError); - tool.result = event.content; - tool.output = formatToolResultContent(event.content); - tool.durationMs = event.durationMs; - tool.resultVersion += 1; + if (!(event.contentOmitted && tool.result?.kind === 'shell_run')) { + tool.durationMs = event.durationMs; + } + if (!event.contentOmitted) { + tool.result = event.content; + tool.resultVersion += 1; + } } } else { state.entries.push({ @@ -695,11 +634,10 @@ export function applyMakaSessionEventToTranscript( input: undefined, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - result: event.content, - output: formatToolResultContent(event.content), - resultVersion: 1, + ...(!event.contentOmitted ? { result: event.content } : {}), + resultVersion: event.contentOmitted ? 0 : 1, durationMs: event.durationMs, - status: toolResultTranscriptStatus(event.content, event.isError), + callStatus: toolResultActivityStatus(event.isError, event.content), expanded: state.expandAllTools, }); } @@ -800,7 +738,7 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); - state.pendingShellRunPolls.clear(); + dropSuppressedTools(state); state.entries.push({ kind: 'notice', level: 'error', @@ -810,7 +748,7 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); - state.pendingShellRunPolls.clear(); + dropSuppressedTools(state); state.entries.push({ kind: 'notice', level: 'info', @@ -821,6 +759,7 @@ 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', @@ -835,77 +774,101 @@ export function applyMakaSessionEventToTranscript( } } -function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { - switch (item.kind) { - case 'user': - return [ - { +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({ kind: - item.message.origin?.kind === 'legacy_automation' + message.origin?.kind === 'legacy_automation' ? 'legacy_automation' - : item.message.origin?.kind === 'goal' + : message.origin?.kind === 'goal' ? 'goal_continuation' : 'user', - 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, + text: message.displayText ?? message.text, }); + 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; } - 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] : []; + 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; } } + return entries; } -function toolActivityToTranscriptEntry(item: ToolActivityItem): MakaPiToolEntry { - const output = item.result - ? formatToolResultContent(item.result) - : item.status === 'interrupted' - ? 'Interrupted before the tool returned a result.' - : undefined; +function storedToolToTranscriptEntry( + call: Extract, + result: Extract | undefined, + turnStatus: ReturnType[number]['status'] | undefined, +): MakaPiToolEntry { const entry: MakaPiToolEntry = { kind: 'tool', - toolUseId: item.toolUseId, - toolName: item.toolName, - ...(item.displayName ? { title: item.displayName } : {}), - input: item.args, + toolUseId: call.id, + toolName: call.toolName, + ...(call.displayName ? { title: call.displayName } : {}), + input: projectToolActivityArgs(call.toolName, call.args), progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - ...(item.result ? { result: item.result } : {}), - ...(output ? { output } : {}), - resultVersion: item.result ? 1 : 0, - ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}), - status: transcriptToolStatus(item.status), + ...(result ? { result: result.content } : {}), + resultVersion: result ? 1 : 0, + ...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}), + callStatus: result + ? toolResultActivityStatus(result.isError, result.content) + : unfinishedToolActivityStatus(turnStatus), 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 (item.result?.kind === 'shell_run' && !item.isError) - applyOwnShellRunResult(entry, item.result); + if (result?.content.kind === 'shell_run' && !result.isError) + applyOwnShellRunResult(entry, result.content); return entry; } @@ -915,7 +878,11 @@ 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.status !== 'error') { + if ( + entry.kind === 'tool' && + entry.result?.kind === 'shell_run' && + entry.callStatus !== 'errored' + ) { const shellRun = entry.result; const parent = [...folded] .reverse() @@ -936,63 +903,66 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra return folded; } -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'; - } -} - -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'; +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]; } -} + 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 applyShellRunResult( entry: MakaPiToolEntry, @@ -1001,9 +971,7 @@ 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, @@ -1017,14 +985,7 @@ 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 { @@ -1062,15 +1023,11 @@ function contextBudgetNoticeText( (candidate) => candidate.decision === 'replaced', ); if (!contextBudget || !decision) return undefined; - const kind = decision.boundaryKind ?? contextBudget.highWaterReason ?? 'context'; - const coveredTurns = decision.coveredTurns ?? contextBudget.historyCompactedTurns; - const coveredEvents = decision.coveredRuntimeEvents ?? contextBudget.historyCompactedEvents; + const kind = decision.boundaryKind ?? 'context'; + const coveredTurns = decision.coveredTurns; + const coveredEvents = decision.coveredRuntimeEvents; const savedTokens = decision.estimatedTokensSaved ?? - tokenDelta( - contextBudget.historyCompactedEstimatedTokensBefore, - contextBudget.historyCompactedEstimatedTokensAfter, - ) ?? tokenDelta(contextBudget.estimatedTokensBefore, contextBudget.estimatedTokensAfter); const parts = [`Context compacted: ${kind}`]; if (coveredTurns !== undefined || coveredEvents !== undefined) { @@ -1136,19 +1093,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.hidden) { + if (entry.kind === 'tool' && entry.suppressed) { 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' && prev?.kind === 'tool'; + const continuesStack = entry.kind === 'tool' && previousVisibleEntry?.kind === 'tool'; if (!continuesStack) lines.push(''); entryFirstLine.set(entry, lines.length); // An entry that sits entirely above the live viewport is in terminal @@ -1165,6 +1122,7 @@ export function renderMakaPiTranscript( lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); + previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; @@ -1228,6 +1186,10 @@ 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 @@ -1333,17 +1295,15 @@ 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: 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. + // 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. return [ 'tool', width, entry.expanded ? 1 : 0, - entry.status, + makaPiToolPresentationStatus(entry), entry.durationMs ?? '', entry.title ?? entry.toolName, entry.progress.version, @@ -1369,25 +1329,20 @@ export function permissionModeLabel(mode: string): string { export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width: number): string { const safeWidth = Math.max(1, width); const sep = ansi.dim(' · '); - // #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) }, + const parts: string[] = [ + ansi.bold(metadata.title), + ansi.dim(permissionModeLabel(metadata.permissionMode)), + 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({ text: ansi.dim(`thinking:${metadata.thinkingLevel}`), dropRank: 3 }); + parts.push(ansi.dim(`thinking:${metadata.thinkingLevel}`)); } if (metadata.orchestrationMode === 'swarm') { - parts.push({ text: ansi.accent('swarm'), dropRank: 4 }); + parts.push(ansi.accent('swarm')); } else if (metadata.orchestrationMode === 'graph') { - parts.push({ text: ansi.accent('graph'), dropRank: 4 }); + parts.push(ansi.accent('graph')); } // An autonomous goal burns tokens between prompts; it must never be // invisible. Terminal goals show nothing (the desktop chip hides them too). @@ -1396,14 +1351,13 @@ 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({ - text: - metadata.goal.status === 'active' - ? ansi.accent(text) - : metadata.goal.status === 'paused' - ? ansi.yellow(text) - : ansi.dim(text), - }); + parts.push( + 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 @@ -1416,82 +1370,32 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width const pct = Math.round((used / metadata.modelContextWindow) * 100); // #1064: color warning — yellow >80%, red >95%, dim otherwise. const ctxColor = pct > 95 ? ansi.red : pct > 80 ? ansi.yellow : ansi.dim; - parts.push({ - text: ctxColor( + parts.push( + ctxColor( `ctx ${formatTokenCount(used)}/${formatTokenCount(metadata.modelContextWindow)} ${pct}%`, ), - }); + ); } else if (metadata.modelContextWindow !== undefined) { // #3371: the window is known but no usage has arrived yet (fresh session, // or the provider doesn't report per-step input tokens). Degrade // explicitly, pi-style, instead of hiding the segment silently — the user // can then tell "not measured yet" apart from "window unknown". - parts.push({ text: ansi.dim(`ctx ?/${formatTokenCount(metadata.modelContextWindow)}`) }); + parts.push(ansi.dim(`ctx ?/${formatTokenCount(metadata.modelContextWindow)}`)); } if (usage) { if (usage.costUsd > 0) { - parts.push({ text: ansi.dim(`$${formatCost(usage.costUsd)}`), dropRank: 1 }); + parts.push(ansi.dim(`$${formatCost(usage.costUsd)}`)); } const totalCache = usage.cacheHitInput + usage.cacheMissInput; if (totalCache > 0) { const hitRate = Math.round((usage.cacheHitInput / totalCache) * 100); - parts.push({ text: ansi.dim(`cache ${hitRate}%`), dropRank: 0 }); + parts.push(ansi.dim(`cache ${hitRate}%`)); } } - parts.push({ text: ansi.dim(metadata.connectionSlug), dropRank: 2 }); + parts.push(ansi.dim(metadata.connectionSlug)); // #1064: shorten cwd to ~-relative path instead of the full path. - 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. - while (lineWidth(kept) > width) { - 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); + parts.push(ansi.dim(shortenCwd(metadata.cwd))); + return fitLine(parts.join(sep), safeWidth); } /** @@ -1698,6 +1602,14 @@ 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,