diff --git a/flake.lock b/flake.lock index 8e47d93..97828e9 100644 --- a/flake.lock +++ b/flake.lock @@ -1348,11 +1348,11 @@ ] }, "locked": { - "lastModified": 1789831082, - "narHash": "sha256-ug4C/JMzkZZ5UEJqhoERnLDy3mQhvPm6OWO6d5/wO00=", + "lastModified": 1790162698, + "narHash": "sha256-lS/ytU4teCMq9/7wiPGP51VL397qTU6goWR5wcwnjyw=", "ref": "refs/heads/main", - "rev": "278162b3f80d67b9141a783cce29167b496424f8", - "revCount": 54, + "rev": "724014c9a948216b6629f96cd035a5e1ad0cef40", + "revCount": 56, "type": "git", "url": "https://tangled.org/did:plc:yyq2r4sag7vtnnd36rvsnnuq" }, diff --git a/modules/programs/tui/pi/default.nix b/modules/programs/tui/pi/default.nix index 851269f..b219ad9 100644 --- a/modules/programs/tui/pi/default.nix +++ b/modules/programs/tui/pi/default.nix @@ -57,7 +57,6 @@ ++ [ ./patches/disable-llama-extension.patch ./patches/disable-main-screen-autowrap.patch - ./patches/reduce-long-session-render-work.patch ]; }); piSuite = inputs.pi-suite.packages.${system}.default.overrideAttrs (old: { diff --git a/modules/programs/tui/pi/extensions/kendex.nix b/modules/programs/tui/pi/extensions/kendex.nix index 5386b0e..111f53a 100644 --- a/modules/programs/tui/pi/extensions/kendex.nix +++ b/modules/programs/tui/pi/extensions/kendex.nix @@ -40,7 +40,7 @@ sessionAutoRename.model = "current"; statusline.enabled = false; - thinkingTimer.enabled = false; + thinkingTimer.enabled = true; workingIndicator.mode = "static"; }; diff --git a/modules/programs/tui/pi/patches/reduce-long-session-render-work.patch b/modules/programs/tui/pi/patches/reduce-long-session-render-work.patch deleted file mode 100644 index 518406d..0000000 --- a/modules/programs/tui/pi/patches/reduce-long-session-render-work.patch +++ /dev/null @@ -1,1826 +0,0 @@ -diff -ruN a/docs/adr/0001-segment-active-tui-rendering.md b/docs/adr/0001-segment-active-tui-rendering.md ---- a/docs/adr/0001-segment-active-tui-rendering.md 1970-01-01 01:00:00.000000000 +0100 -+++ b/docs/adr/0001-segment-active-tui-rendering.md 2026-09-12 11:36:03.919507267 +0200 -@@ -0,0 +1,19 @@ -+--- -+status: accepted -+--- -+ -+# Segment active TUI rendering by transcript stability -+ -+Active renders will process explicit TUI render regions instead of rebuilding the full transcript. Pi will promote the current Dynamic transcript tail into the Stable transcript at `agent_end`; ordinary text streaming and spinner renders will reuse the stable region, while explicit presentation changes may invalidate it. Existing parameterless `requestRender()` calls remain conservative full invalidations for extension compatibility, and core high-frequency paths opt into region-scoped rendering. -+ -+## Considered Options -+ -+Per-line memoization reduces allocation but remains proportional to session length. Freezing prior transcript content would be faster but would break historical tool expansion, theme and image settings, and extension renderer invalidation. Application-only caching cannot prevent renderer-level reset, image, and diff scans. -+ -+## Consequences -+ -+Regular and fullscreen renderers share the region contract. Promotion must not change displayed content, scrollback, selection, overlays, images, viewport, or mode-switch state when transcript content is unchanged; harmless terminal cursor control remains allowed. Stable snapshots retain one prepared line representation plus sparse image metadata rather than duplicating the transcript. Cumulative usage becomes append-only core session state. Context usage keeps its completed-message semantics and caches results until the completed message state changes. -+ -+Active renders with overlays, dynamic images, dynamic line shrinkage, terminal resize, or an invalid stable revision fall back to the conservative full path. Correct terminal state takes priority over making these low-frequency cases independent of transcript length. -+ -+This decision addresses render-path CPU growth with completed transcript length. Session memory retention and incremental rebuilding of the current streaming message require separate evidence and are outside this decision. -diff -ruN a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts ---- a/packages/coding-agent/src/core/agent-session.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/src/core/agent-session.ts 2026-09-12 11:36:48.493963860 +0200 -@@ -111,7 +111,6 @@ - import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; - import { createAllToolDefinitions } from "./tools/index.ts"; - import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; --import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts"; - - // ============================================================================ - // Skill Block Parsing -@@ -638,6 +637,20 @@ - - // Track last assistant message for auto-compaction check - private _lastAssistantMessage: AssistantMessage | undefined = undefined; -+ private _contextUsageRevision = 0; -+ private _contextUsageCache: -+ | { -+ revision: number; -+ model: Model; -+ contextWindow: number; -+ value: ContextUsage; -+ } -+ | undefined; -+ -+ private _invalidateContextUsage(): void { -+ this._contextUsageRevision += 1; -+ this._contextUsageCache = undefined; -+ } - - /** Internal handler for agent events - shared by subscribe and reconnect */ - private _handleAgentEvent = async (event: AgentEvent): Promise => { -@@ -662,6 +675,7 @@ - } - } - } -+ if (event.type === "message_end") this._invalidateContextUsage(); - - // Emit to extensions first - await this._emitExtensionEvent(event); -@@ -688,6 +702,7 @@ - // Regular LLM message - persist as SessionMessageEntry - this.sessionManager.appendMessage(event.message); - } -+ this._invalidateContextUsage(); - // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere - - // Track assistant message for auto-compaction (checked on agent_end) -@@ -1515,6 +1530,7 @@ - - private _appendCustomMessage(appMessage: CustomMessage): void { - this.agent.state.messages.push(appMessage); -+ this._invalidateContextUsage(); - this.sessionManager.appendCustomMessageEntry( - appMessage.customType, - appMessage.content, -@@ -2032,6 +2048,7 @@ - const newEntries = this.sessionManager.getEntries(); - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; -+ this._invalidateContextUsage(); - const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); - - // Get the saved compaction entry for the extension event -@@ -2197,6 +2214,7 @@ - const messages = this.agent.state.messages; - if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { - this.agent.state.messages = messages.slice(0, -1); -+ this._invalidateContextUsage(); - } - return await this._runAutoCompaction("overflow", willRetry); - } -@@ -2357,6 +2375,7 @@ - const newEntries = this.sessionManager.getEntries(); - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; -+ this._invalidateContextUsage(); - const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); - - // Get the saved compaction entry for the extension event -@@ -2393,6 +2412,7 @@ - // the retriable error or truncated-length response again before continuing the interrupted turn. - if (lastMsg?.role === "assistant" && (lastMsg.stopReason === "error" || lastMsg.stopReason === "length")) { - this.agent.state.messages = messages.slice(0, -1); -+ this._invalidateContextUsage(); - } - return true; - } -@@ -2919,6 +2939,7 @@ - const messages = this.agent.state.messages; - if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { - this.agent.state.messages = messages.slice(0, -1); -+ this._invalidateContextUsage(); - } - - // Wait with exponential backoff (abortable) -@@ -3038,6 +3059,7 @@ - } else { - // Add to agent state immediately - this.agent.state.messages.push(bashMessage); -+ this._invalidateContextUsage(); - - // Save to session - this.sessionManager.appendMessage(bashMessage); -@@ -3073,6 +3095,7 @@ - for (const bashMessage of this._pendingBashMessages) { - // Add to agent state - this.agent.state.messages.push(bashMessage); -+ this._invalidateContextUsage(); - - // Save to session - this.sessionManager.appendMessage(bashMessage); -@@ -3284,6 +3307,7 @@ - // Update agent state - const sessionContext = this.sessionManager.buildSessionContext(); - this.agent.state.messages = sessionContext.messages; -+ this._invalidateContextUsage(); - - // Emit session_tree event - await this._extensionRunner.emit({ -@@ -3334,12 +3358,9 @@ - let toolResults = 0; - let totalMessages = 0; - let toolCalls = 0; -- const usageTotals = createUsageTotals(); -+ const usageTotals = this.sessionManager.getUsageSnapshot().totals; - - for (const entry of this.sessionManager.getEntries()) { -- if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { -- addUsageToTotals(usageTotals, entry.usage); -- } - if (entry.type !== "message") continue; - totalMessages++; - const message = entry.message; -@@ -3347,16 +3368,12 @@ - userMessages++; - } else if (message.role === "toolResult") { - toolResults++; -- if (message.usage) { -- addUsageToTotals(usageTotals, message.usage); -- } - } else if (message.role === "assistant") { - assistantMessages++; - const assistantMsg = message as AssistantMessage; - if (Array.isArray(assistantMsg.content)) { - toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; - } -- addUsageToTotals(usageTotals, assistantMsg.usage); - } - } - -@@ -3386,6 +3403,22 @@ - - const contextWindow = model.contextWindow ?? 0; - if (contextWindow <= 0) return undefined; -+ if ( -+ this._contextUsageCache?.revision === this._contextUsageRevision && -+ this._contextUsageCache.model === model && -+ this._contextUsageCache.contextWindow === contextWindow -+ ) { -+ return { ...this._contextUsageCache.value }; -+ } -+ const cache = (value: ContextUsage): ContextUsage => { -+ this._contextUsageCache = { -+ revision: this._contextUsageRevision, -+ model, -+ contextWindow, -+ value, -+ }; -+ return { ...value }; -+ }; - - // After compaction, the last assistant usage reflects pre-compaction context size. - // We can only trust usage from an assistant that responded after the latest compaction. -@@ -3412,18 +3445,18 @@ - } - - if (!hasPostCompactionUsage) { -- return { tokens: null, contextWindow, percent: null }; -+ return cache({ tokens: null, contextWindow, percent: null }); - } - } - - const estimate = estimateContextTokens(this.messages); - const percent = (estimate.tokens / contextWindow) * 100; - -- return { -+ return cache({ - tokens: estimate.tokens, - contextWindow, - percent, -- }; -+ }); - } - - /** -diff -ruN a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts ---- a/packages/coding-agent/src/core/session-manager.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/src/core/session-manager.ts 2026-09-12 11:36:03.920058905 +0200 -@@ -26,6 +26,7 @@ - createCompactionSummaryMessage, - createCustomMessage, - } from "./messages.ts"; -+import { addUsageToTotals, createUsageTotals, type UsageTotals } from "./usage-totals.ts"; - - export const CURRENT_SESSION_VERSION = 3; - -@@ -187,6 +188,11 @@ - allMessagesText: string; - } - -+export interface SessionUsageSnapshot { -+ totals: UsageTotals; -+ latestCacheHitRate: number | undefined; -+} -+ - export type ReadonlySessionManager = Pick< - SessionManager, - | "getCwd" -@@ -209,6 +215,33 @@ - return uuidv7(); - } - -+function createSessionUsageSnapshot(): SessionUsageSnapshot { -+ return { -+ totals: createUsageTotals(), -+ latestCacheHitRate: undefined, -+ }; -+} -+ -+function getEntryUsage(entry: SessionEntry): Usage | undefined { -+ if (entry.type === "message") { -+ const message = entry.message as AgentMessage & { usage?: Usage }; -+ if (message.role === "assistant" || message.role === "toolResult") return message.usage; -+ } -+ if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) return entry.usage; -+ return undefined; -+} -+ -+function addEntryUsage(snapshot: SessionUsageSnapshot, entry: SessionEntry): void { -+ const usage = getEntryUsage(entry); -+ if (!usage) return; -+ addUsageToTotals(snapshot.totals, usage); -+ -+ if (entry.type === "message" && entry.message.role === "assistant") { -+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite; -+ snapshot.latestCacheHitRate = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined; -+ } -+} -+ - export function assertValidSessionId(id: string): void { - if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { - throw new Error( -@@ -865,6 +898,7 @@ - private labelsById: Map = new Map(); - private labelTimestampsById: Map = new Map(); - private leafId: string | null = null; -+ private usageSnapshot = createSessionUsageSnapshot(); - - private constructor( - cwd: string, -@@ -942,6 +976,7 @@ - this.labelsById.clear(); - this.labelTimestampsById.clear(); - this.leafId = null; -+ this.usageSnapshot = createSessionUsageSnapshot(); - this.flushed = false; - - if (this.persist) { -@@ -974,10 +1009,12 @@ - this.labelsById.clear(); - this.labelTimestampsById.clear(); - this.leafId = null; -+ this.usageSnapshot = createSessionUsageSnapshot(); - for (const entry of this.fileEntries) { - if (entry.type === "session") continue; - this.byId.set(entry.id, entry); - this.leafId = entry.id; -+ addEntryUsage(this.usageSnapshot, entry); - if (entry.type === "label") { - if (entry.label) { - this.labelsById.set(entry.targetId, entry.label); -@@ -1059,6 +1096,7 @@ - this.fileEntries.push(entry); - this.byId.set(entry.id, entry); - this.leafId = entry.id; -+ addEntryUsage(this.usageSnapshot, entry); - this._persist(entry); - } - -@@ -1316,6 +1354,13 @@ - return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); - } - -+ getUsageSnapshot(): SessionUsageSnapshot { -+ return { -+ totals: { ...this.usageSnapshot.totals }, -+ latestCacheHitRate: this.usageSnapshot.latestCacheHitRate, -+ }; -+ } -+ - /** - * Get the session as a tree structure. Returns a shallow defensive copy of all entries. - * A well-formed session has exactly one root (first entry with parentId === null). -diff -ruN a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts ---- a/packages/coding-agent/src/modes/interactive/components/footer.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/src/modes/interactive/components/footer.ts 2026-09-12 11:36:03.920178734 +0200 -@@ -3,7 +3,6 @@ - import type { AgentSession } from "../../../core/agent-session.ts"; - import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; - import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; --import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.ts"; - import { theme } from "../theme/theme.ts"; - - /** -@@ -84,24 +83,7 @@ - render(width: number): string[] { - const state = this.session.state; - -- // Calculate cumulative usage from ALL session entries (not just post-compaction messages) -- const usageTotals = createUsageTotals(); -- let latestCacheHitRate: number | undefined; -- -- for (const entry of this.session.sessionManager.getEntries()) { -- if (entry.type === "message" && entry.message.role === "assistant") { -- addUsageToTotals(usageTotals, entry.message.usage); -- -- const latestPromptTokens = -- entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; -- latestCacheHitRate = -- latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; -- } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { -- addUsageToTotals(usageTotals, entry.message.usage); -- } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { -- addUsageToTotals(usageTotals, entry.usage); -- } -- } -+ const { totals: usageTotals, latestCacheHitRate } = this.session.sessionManager.getUsageSnapshot(); - - // Calculate context usage from session (handles compaction correctly). - // After compaction, tokens are unknown until the next LLM response. -diff -ruN a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts ---- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts 2026-09-12 11:36:03.920240695 +0200 -@@ -135,7 +135,8 @@ - toolCallId: this.toolCallId, - invalidate: () => { - this.invalidate(); -- this.ui.requestRender(); -+ if (this.isPartial && this.ui.requestActiveRender) this.ui.requestActiveRender(); -+ else this.ui.requestRender(); - }, - lastComponent, - state: this.rendererState, -@@ -185,13 +186,15 @@ - markExecutionStarted(): void { - this.executionStarted = true; - this.updateDisplay(); -- this.ui.requestRender(); -+ if (this.ui.requestActiveRender) this.ui.requestActiveRender(); -+ else this.ui.requestRender(); - } - - setArgsComplete(): void { - this.argsComplete = true; - this.updateDisplay(); -- this.ui.requestRender(); -+ if (this.ui.requestActiveRender) this.ui.requestActiveRender(); -+ else this.ui.requestRender(); - } - - updateResult( -@@ -225,7 +228,8 @@ - if (converted) { - this.convertedImages.set(index, converted); - this.updateDisplay(); -- this.ui.requestRender(); -+ if (this.isPartial && this.ui.requestActiveRender) this.ui.requestActiveRender(); -+ else this.ui.requestRender(); - } - }); - } -diff -ruN a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts ---- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-09-12 11:37:04.671179303 +0200 -@@ -261,6 +261,43 @@ - return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown"; - } - -+function createRenderRegion(kind: "stable" | "dynamic", children: readonly Component[]): Container { -+ const container = new Container(); -+ for (const child of children) container.addChild(child); -+ container.setRenderRegion?.(kind); -+ return container; -+} -+ -+function getTranscriptContainers(stable: Container | undefined, dynamic: Container): readonly Container[] { -+ return stable ? [stable, dynamic] : [dynamic]; -+} -+ -+function hasTranscriptContent(stable: Container | undefined, dynamic: Container): boolean { -+ return (stable?.children.length ?? 0) > 0 || dynamic.children.length > 0; -+} -+ -+function promoteDynamicTranscript( -+ stable: Container | undefined, -+ dynamic: Container, -+ stableRegion: Container | undefined, -+): void { -+ if (!stable || dynamic.children.length === 0) return; -+ stable.children.push(...dynamic.children); -+ dynamic.children = []; -+ stableRegion?.expireRenderRegion?.(); -+} -+ -+function clearTranscript(stable: Container | undefined, dynamic: Container, stableRegion: Container | undefined): void { -+ stable?.clear(); -+ dynamic.clear(); -+ stableRegion?.expireRenderRegion?.(); -+} -+ -+function requestActiveRender(tui: TUI): void { -+ if (tui.requestActiveRender) tui.requestActiveRender(); -+ else tui.requestRender(); -+} -+ - function quoteIfNeeded(value: string): string { - if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) { - return value; -@@ -378,8 +415,11 @@ - private ui: TUI; - private mainScreenRenderState: TuiMainScreenRenderState | undefined; - private loadedResourcesContainer: Container; -+ private stableChatContainer: Container; - private chatContainer: Container; -- private documentContainer: Container; -+ private stableTranscriptRegion: Container; -+ private dynamicTranscriptRegion: Container; -+ private documentContainer: Component; - private transcriptScrollView: TuiLayouts.ScrollView | undefined; - private fullscreenLayoutRoot: Component | undefined; - private pendingMessagesContainer: Container; -@@ -542,11 +582,15 @@ - this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); - this.headerContainer = new Container(); - this.loadedResourcesContainer = new Container(); -+ this.stableChatContainer = new Container(); - this.chatContainer = new Container(); -- this.documentContainer = new Container(); -- this.documentContainer.addChild(this.headerContainer); -- this.documentContainer.addChild(this.loadedResourcesContainer); -- this.documentContainer.addChild(this.chatContainer); -+ this.stableTranscriptRegion = createRenderRegion("stable", [ -+ this.headerContainer, -+ this.loadedResourcesContainer, -+ this.stableChatContainer, -+ ]); -+ this.dynamicTranscriptRegion = createRenderRegion("dynamic", [this.chatContainer]); -+ this.documentContainer = new TuiLayouts.VStack([this.stableTranscriptRegion, this.dynamicTranscriptRegion]); - this.pendingMessagesContainer = new Container(); - this.statusContainer = new Container(); - this.widgetContainerAbove = new Container(); -@@ -759,7 +803,7 @@ - return; - } - -- if (this.chatContainer.children.length > 0) { -+ if (hasTranscriptContent(this.stableChatContainer, this.chatContainer)) { - this.chatContainer.addChild(new Spacer(1)); - } - this.chatContainer.addChild(new DynamicBorder()); -@@ -888,7 +932,8 @@ - this.transcriptScrollView = viewport.transcript; - this.fullscreenLayoutRoot = viewport.root; - this.mountInteractiveTui(this.renderer, [ -- this.documentContainer, -+ this.stableTranscriptRegion, -+ this.dynamicTranscriptRegion, - this.pendingMessagesContainer, - this.statusContainer, - this.widgetContainerAbove, -@@ -1896,7 +1941,7 @@ - return { cancelled: true }; - } - -- this.chatContainer.clear(); -+ clearTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.renderInitialMessages(); - if (result.editorText && !this.editor.getText().trim()) { - this.editor.setText(result.editorText); -@@ -2001,7 +2046,7 @@ - - private renderCurrentSessionState(): void { - this.loadedResourcesContainer.clear(); -- this.chatContainer.clear(); -+ clearTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.pendingMessagesContainer.clear(); - this.compactionQueuedMessages = []; - this.streamingComponent = undefined; -@@ -2171,9 +2216,11 @@ - - private setHiddenThinkingLabel(label?: string): void { - this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel; -- for (const child of this.chatContainer.children) { -- if (child instanceof AssistantMessageComponent) { -- child.setHiddenThinkingLabel(this.hiddenThinkingLabel); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ for (const child of container.children) { -+ if (child instanceof AssistantMessageComponent) { -+ child.setHiddenThinkingLabel(this.hiddenThinkingLabel); -+ } - } - } - if (this.streamingComponent) { -@@ -3171,6 +3218,7 @@ - - switch (event.type) { - case "agent_start": -+ promoteDynamicTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.pendingTools.clear(); - // Restore main escape handler if retry handler is still active - // (retry success event fires later, but we need main handler now) -@@ -3191,18 +3239,18 @@ - } else { - this.clearStatusIndicator(); - } -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - - case "queue_update": - this.updatePendingMessagesDisplay(); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - - case "entry_appended": - if (event.entry.type === "custom") { - this.addCustomEntryToChat(event.entry); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } - break; - -@@ -3220,11 +3268,11 @@ - case "message_start": - if (event.message.role === "custom") { - this.addMessageToChat(event.message); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } else if (event.message.role === "user") { - this.addMessageToChat(event.message); - this.updatePendingMessagesDisplay(); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } else if (event.message.role === "assistant") { - this.streamingComponent = new AssistantMessageComponent( - undefined, -@@ -3237,7 +3285,7 @@ - this.streamingMessage = event.message; - this.chatContainer.addChild(this.streamingComponent); - this.streamingComponent.updateContent(this.streamingMessage, true); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } - break; - -@@ -3272,7 +3320,7 @@ - } - } - } -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } - break; - -@@ -3314,7 +3362,7 @@ - this.streamingMessage = undefined; - this.footer.invalidate(); - } -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - - case "bash_execution_update": -@@ -3341,7 +3389,7 @@ - this.pendingTools.set(event.toolCallId, component); - } - component.markExecutionStarted(); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - -@@ -3349,7 +3397,7 @@ - const component = this.pendingTools.get(event.toolCallId); - if (component) { - component.updateResult({ ...event.partialResult, isError: false }, true); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } - break; - } -@@ -3359,7 +3407,7 @@ - if (component) { - component.updateResult({ ...event.result, isError: event.isError }); - this.pendingTools.delete(event.toolCallId); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - } - break; - } -@@ -3375,7 +3423,7 @@ - this.streamingMessage = undefined; - } - this.pendingTools.clear(); -- -+ promoteDynamicTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.ui.requestRender(); - break; - -@@ -3393,7 +3441,7 @@ - this.session.abortCompaction(); - }; - this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - -@@ -3417,7 +3465,7 @@ - if (entries[0]?.type !== "compaction") { - throw new Error("Completed compaction is missing from the session context"); - } -- this.chatContainer.clear(); -+ clearTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - // The latest compaction is prepended for model context; append it below at its chronological position. - this.renderSessionEntries(entries.slice(1)); - this.addMessageToChat( -@@ -3457,7 +3505,7 @@ - this.showStatusIndicator( - new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), - ); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - -@@ -3472,7 +3520,7 @@ - if (!event.success) { - this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); - } -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - -@@ -3481,7 +3529,7 @@ - this.showStatusIndicator( - new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs), - ); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - -@@ -3492,13 +3540,13 @@ - } else { - this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason)); - } -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - - case "summarization_retry_finished": { - this.clearStatusIndicator("retry"); -- this.ui.requestRender(); -+ requestActiveRender(this.ui); - break; - } - } -@@ -3623,7 +3671,7 @@ - case "user": { - const textContent = this.getUserMessageText(message); - if (textContent) { -- if (this.chatContainer.children.length > 0) { -+ if (hasTranscriptContent(this.stableChatContainer, this.chatContainer)) { - this.chatContainer.addChild(new Spacer(1)); - } - const skillBlock = parseSkillBlock(textContent); -@@ -3883,6 +3931,7 @@ - const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; - this.showStatus(`Session compacted ${times}`); - } -+ promoteDynamicTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - } - - private renderProjectTrustWarningIfNeeded(): void { -@@ -3890,7 +3939,7 @@ - return; - } - -- if (this.chatContainer.children.length > 0) { -+ if (hasTranscriptContent(this.stableChatContainer, this.chatContainer)) { - this.chatContainer.addChild(new Spacer(1)); - } - this.chatContainer.addChild( -@@ -3920,8 +3969,9 @@ - } - - private rebuildChatFromMessages(): void { -- this.chatContainer.clear(); -+ clearTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.renderSessionEntries(this.sessionManager.buildContextEntries()); -+ promoteDynamicTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - } - - // ========================================================================= -@@ -4218,7 +4268,10 @@ - if (isExpandable(activeHeader)) { - activeHeader.setExpanded(expanded); - } -- for (const container of [this.loadedResourcesContainer, this.chatContainer]) { -+ for (const container of [ -+ this.loadedResourcesContainer, -+ ...getTranscriptContainers(this.stableChatContainer, this.chatContainer), -+ ]) { - for (const child of container.children) { - if (isExpandable(child)) { - child.setExpanded(expanded); -@@ -4230,9 +4283,11 @@ - - /** Update rendered assistant messages without rebuilding live tool components. */ - private updateThinkingBlockVisibility(): void { -- for (const child of this.chatContainer.children) { -- if (child instanceof AssistantMessageComponent) { -- child.setHideThinkingBlock(this.hideThinkingBlock); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ for (const child of container.children) { -+ if (child instanceof AssistantMessageComponent) { -+ child.setHideThinkingBlock(this.hideThinkingBlock); -+ } - } - } - this.ui.requestRender(); -@@ -4608,17 +4663,21 @@ - }, - onShowImagesChange: (enabled) => { - this.settingsManager.setShowImages(enabled); -- for (const child of this.chatContainer.children) { -- if (child instanceof ToolExecutionComponent) { -- child.setShowImages(enabled); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ for (const child of container.children) { -+ if (child instanceof ToolExecutionComponent) { -+ child.setShowImages(enabled); -+ } - } - } - }, - onImageWidthCellsChange: (width) => { - this.settingsManager.setImageWidthCells(width); -- for (const child of this.chatContainer.children) { -- if (child instanceof ToolExecutionComponent) { -- child.setImageWidthCells(width); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ for (const child of container.children) { -+ if (child instanceof ToolExecutionComponent) { -+ child.setImageWidthCells(width); -+ } - } - } - }, -@@ -4680,7 +4739,9 @@ - }, - onMermaidRenderingModeChange: (mode) => { - this.settingsManager.setMermaidRenderingMode(mode); -- this.chatContainer.invalidate(); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ container.invalidate(); -+ } - this.ui.requestRender(); - }, - onShowCacheMissNoticesChange: (shown) => { -@@ -4720,13 +4781,15 @@ - this.settingsManager.setOutputPad(padding); - this.outputPad = padding; - if (this.streamingComponent || this.session.isStreaming) { -- for (const child of this.chatContainer.children) { -- if ( -- child instanceof AssistantMessageComponent || -- child instanceof CustomMessageComponent || -- child instanceof UserMessageComponent -- ) { -- child.setOutputPad(padding); -+ for (const container of getTranscriptContainers(this.stableChatContainer, this.chatContainer)) { -+ for (const child of container.children) { -+ if ( -+ child instanceof AssistantMessageComponent || -+ child instanceof CustomMessageComponent || -+ child instanceof UserMessageComponent -+ ) { -+ child.setOutputPad(padding); -+ } - } - } - if (this.streamingComponent) { -@@ -5302,7 +5365,7 @@ - } - - // Update UI -- this.chatContainer.clear(); -+ clearTranscript(this.stableChatContainer, this.chatContainer, this.stableTranscriptRegion); - this.renderInitialMessages(); - if (result.editorText && !this.editor.getText().trim()) { - this.editor.setText(result.editorText); -diff -ruN a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts ---- a/packages/coding-agent/test/agent-session-stats.test.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/test/agent-session-stats.test.ts 2026-09-12 11:36:03.921206075 +0200 -@@ -1,3 +1,6 @@ -+import { mkdtempSync, rmSync } from "node:fs"; -+import { tmpdir } from "node:os"; -+import { join } from "node:path"; - import { Agent } from "@earendil-works/pi-agent-core"; - import { - type AssistantMessage, -@@ -6,7 +9,7 @@ - type ToolResultMessage, - type Usage, - } from "@earendil-works/pi-ai/compat"; --import { describe, expect, it } from "vitest"; -+import { describe, expect, it, vi } from "vitest"; - import { AgentSession } from "../src/core/agent-session.ts"; - import { AuthStorage } from "../src/core/auth-storage.ts"; - import { SessionManager } from "../src/core/session-manager.ts"; -@@ -98,6 +101,28 @@ - } - - describe("AgentSession.getSessionStats", () => { -+ it("rebuilds usage snapshots when reopening a session", () => { -+ const tempDir = mkdtempSync(join(tmpdir(), "pi-usage-snapshot-")); -+ try { -+ const manager = SessionManager.create(tempDir, tempDir); -+ manager.appendMessage(createUserMessage("hello", 1)); -+ manager.appendMessage({ -+ ...createAssistantMessage("hi", 200, 2), -+ usage: { -+ ...createUsage(200), -+ cacheRead: 50, -+ cacheWrite: 50, -+ cost: { ...createUsage(200).cost, total: 1.25 }, -+ }, -+ }); -+ -+ const reopened = SessionManager.open(manager.getSessionFile()!); -+ expect(reopened.getUsageSnapshot()).toEqual(manager.getUsageSnapshot()); -+ } finally { -+ rmSync(tempDir, { recursive: true, force: true }); -+ } -+ }); -+ - it("exposes the current context usage alongside token totals", async () => { - const { session, sessionManager } = await createSession(); - -@@ -114,6 +139,36 @@ - } finally { - session.dispose(); - } -+ }); -+ -+ it("reuses context usage until session state changes and returns defensive copies", async () => { -+ const { session, sessionManager } = await createSession(); -+ -+ try { -+ sessionManager.appendMessage(createUserMessage("hello", 1)); -+ sessionManager.appendMessage(createAssistantMessage("hi", 200, 2)); -+ syncAgentMessages(session, sessionManager); -+ const getBranch = vi.spyOn(sessionManager, "getBranch"); -+ -+ const first = session.getContextUsage(); -+ expect(first?.tokens).toBe(200); -+ expect(session.getContextUsage()?.tokens).toBe(200); -+ expect(getBranch).toHaveBeenCalledTimes(1); -+ if (!first) throw new Error("expected context usage"); -+ first.tokens = 999; -+ expect(session.getContextUsage()?.tokens).toBe(200); -+ -+ await session.sendCustomMessage({ -+ customType: "test", -+ content: [{ type: "text", text: "12345678" }], -+ display: false, -+ }); -+ -+ expect(session.getContextUsage()?.tokens).toBe(202); -+ expect(getBranch).toHaveBeenCalledTimes(2); -+ } finally { -+ session.dispose(); -+ } - }); - - it("reports unknown current context usage immediately after compaction", async () => { -diff -ruN a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts ---- a/packages/coding-agent/test/footer-width.test.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/coding-agent/test/footer-width.test.ts 2026-09-12 11:36:03.921256042 +0200 -@@ -63,6 +63,25 @@ - }); - } - -+ const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; -+ let latestCacheHitRate: number | undefined; -+ for (const entry of entries) { -+ const entryUsage = -+ entry.type === "message" -+ ? (entry.message as { usage?: AssistantUsage }).usage -+ : (entry.usage as AssistantUsage | undefined); -+ if (!entryUsage) continue; -+ usageTotals.input += entryUsage.input; -+ usageTotals.output += entryUsage.output; -+ usageTotals.cacheRead += entryUsage.cacheRead; -+ usageTotals.cacheWrite += entryUsage.cacheWrite; -+ usageTotals.cost += entryUsage.cost.total; -+ if (entry.type === "message" && (entry.message as { role?: string }).role === "assistant") { -+ const promptTokens = entryUsage.input + entryUsage.cacheRead + entryUsage.cacheWrite; -+ latestCacheHitRate = promptTokens > 0 ? (entryUsage.cacheRead / promptTokens) * 100 : undefined; -+ } -+ } -+ - const session = { - state: { - model: { -@@ -75,6 +94,7 @@ - }, - sessionManager: { - getEntries: () => entries, -+ getUsageSnapshot: () => ({ totals: usageTotals, latestCacheHitRate }), - getSessionName: () => options.sessionName, - getCwd: () => "/tmp/project", - }, -@@ -207,6 +227,25 @@ - expect(statsLine).toContain("CH25.0%"); - }); - -+ it("does not scan session entries while rendering", () => { -+ const session = createSession({ -+ sessionName: "", -+ usage: { -+ input: 100, -+ output: 10, -+ cacheRead: 50, -+ cacheWrite: 50, -+ cost: { total: 0.001 }, -+ }, -+ }); -+ session.sessionManager.getEntries = () => { -+ throw new Error("footer render must not scan session entries"); -+ }; -+ const footer = new FooterComponent(session, createFooterData(1)); -+ -+ expect(() => footer.render(120)).not.toThrow(); -+ }); -+ - it("marks Kimi Coding costs as subscription estimates", () => { - const session = createSession({ - sessionName: "", -diff -ruN a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts ---- a/packages/tui/src/components/loader.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/src/components/loader.ts 2026-09-12 11:36:03.921294966 +0200 -@@ -95,7 +95,8 @@ - const indicator = renderedFrame.length > 0 ? `${renderedFrame} ` : ""; - this.setText(`${indicator}${this.messageColorFn(this.message)}`); - if (this.ui) { -- this.ui.requestRender(); -+ if (this.ui.requestActiveRender) this.ui.requestActiveRender(); -+ else this.ui.requestRender(); - } - } - } -diff -ruN a/packages/tui/src/index.ts b/packages/tui/src/index.ts ---- a/packages/tui/src/index.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/src/index.ts 2026-09-12 11:36:03.921321276 +0200 -@@ -129,6 +129,7 @@ - type OverlayMargin, - type OverlayOptions, - type OverlayUnfocusOptions, -+ type RenderRegionKind, - type SizeValue, - type TUI, - type TuiInputListener, -diff -ruN a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts ---- a/packages/tui/src/layout.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/src/layout.ts 2026-09-12 11:36:03.921355705 +0200 -@@ -159,7 +159,9 @@ - clip: childClip, - children: [childBox], - scrollView, -- scrollContentLines: renderCached(context, node.component, contentWidth), -+ ...(getLayoutNode(node.component) -+ ? {} -+ : { scrollContentLines: renderCached(context, node.component, contentWidth) }), - layer: 0, - }; - childBox.parent = box; -@@ -327,6 +329,16 @@ - } - } - -+function getLineAtRow(box: LayoutBox, row: number): string | undefined { -+ if (row < box.rect.y || row >= box.rect.y + box.rect.height) return undefined; -+ if (box.lines) return box.lines[(box.lineOffset ?? 0) + row - box.rect.y]; -+ for (const child of box.children) { -+ const line = getLineAtRow(child, row); -+ if (line !== undefined) return line; -+ } -+ return undefined; -+} -+ - function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { - if (box.lines) { - const offset = box.lineOffset ?? 0; -@@ -356,9 +368,13 @@ - } - for (const child of box.children) paintBox(child, screen, totalWidth); - -- if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) { -+ if (box.scrollView && box.scrollView.scrollTop > 0 && box.rect.height > 0) { -+ const scrollRoot = box.children[0]; - for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) { -- const imageLine = box.scrollContentLines[imageRow] ?? ""; -+ const imageLine = -+ box.scrollContentLines?.[imageRow] ?? -+ (scrollRoot ? getLineAtRow(scrollRoot, scrollRoot.rect.y + imageRow) : undefined) ?? -+ ""; - const metadata = getKittyImageMetadata(imageLine); - if (metadata) { - const hiddenRows = box.scrollView.scrollTop - imageRow; -diff -ruN a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts ---- a/packages/tui/src/tui-alt-screen.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/src/tui-alt-screen.ts 2026-09-12 11:36:03.921447972 +0200 -@@ -1649,7 +1649,7 @@ - return result; - } - -- protected override doRender(): void { -+ protected override doRender(_scope: "active" | "full"): void { - if (this.stopped || !this.altScreenActive) return; - const width = Math.max(1, this.terminal.columns); - const height = Math.max(1, this.terminal.rows); -diff -ruN a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts ---- a/packages/tui/src/tui-main-screen.ts 2026-09-12 11:35:59.093386985 +0200 -+++ b/packages/tui/src/tui-main-screen.ts 2026-09-12 11:36:03.921547697 +0200 -@@ -2,7 +2,7 @@ - import * as os from "node:os"; - import * as path from "node:path"; - import { deleteKittyImage, isImageLine } from "./terminal-image.ts"; --import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts"; -+import { Container, type TUI, TuiBase, type TuiStopOptions } from "./tui.ts"; - import { visibleWidth } from "./utils.ts"; - - const KITTY_SEQUENCE_PREFIX = "\x1b_G"; -@@ -137,6 +137,8 @@ - private hardwareCursorRow = 0; - private maxLinesRendered = 0; - private previousViewportTop = 0; -+ private stableRegion: { component: Container; revision: number; width: number; lineCount: number } | undefined; -+ private previousDynamicLines: string[] = []; - - captureRenderState(): TuiMainScreenRenderState { - return { -@@ -152,6 +154,8 @@ - - restoreRenderState(state: TuiMainScreenRenderState): void { - this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line)); -+ this.stableRegion = undefined; -+ this.previousDynamicLines = []; - this.previousKittyImageIds = new Set(); - this.previousWidth = state.previousWidth; - this.previousHeight = state.previousHeight; -@@ -163,6 +167,8 @@ - - protected override resetRenderState(): void { - this.previousLines = []; -+ this.stableRegion = undefined; -+ this.previousDynamicLines = []; - this.previousWidth = -1; - this.previousHeight = -1; - this.cursorRow = 0; -@@ -250,10 +256,142 @@ - return this.deleteKittyImages(ids); - } - -- protected doRender(): void { -+ private getStablePrefix(width: number): { component: Container; lineCount: number } | undefined { -+ const component = this.children[0]; -+ if (!(component instanceof Container) || component.renderRegionKind !== "stable") return undefined; -+ const lineCount = component.getCachedLineCount(width); -+ return lineCount === undefined ? undefined : { component, lineCount }; -+ } -+ -+ private captureRegionState(width: number, lines: string[]): void { -+ const stablePrefix = this.getStablePrefix(width); -+ if (!stablePrefix) { -+ this.stableRegion = undefined; -+ this.previousDynamicLines = []; -+ return; -+ } -+ this.stableRegion = { -+ component: stablePrefix.component, -+ revision: stablePrefix.component.renderRegionRevision, -+ width, -+ lineCount: stablePrefix.lineCount, -+ }; -+ this.previousDynamicLines = lines.slice(stablePrefix.lineCount); -+ } -+ -+ private renderDynamicLines(width: number): string[] { -+ const lines: string[] = []; -+ for (let index = 1; index < this.children.length; index++) { -+ for (const line of this.children[index]!.render(width)) lines.push(line); -+ } -+ return lines; -+ } -+ -+ private tryActiveRender(width: number, height: number): boolean { -+ const stable = this.stableRegion; -+ if ( -+ !stable || -+ this.hasOverlayEntries || -+ this.previousWidth !== width || -+ this.previousHeight !== height || -+ this.children[0] !== stable.component || -+ stable.component.renderRegionRevision !== stable.revision || -+ stable.component.getCachedLineCount(width) !== stable.lineCount -+ ) { -+ return false; -+ } -+ -+ let newDynamicLines = this.renderDynamicLines(width); -+ const cursorPos = this.extractCursorPosition(newDynamicLines, height); -+ newDynamicLines = this.applyLineResets(newDynamicLines); -+ if ( -+ newDynamicLines.some(isImageLine) || -+ this.previousDynamicLines.some(isImageLine) || -+ newDynamicLines.length < this.previousDynamicLines.length -+ ) { -+ return false; -+ } -+ -+ let firstChanged = -1; -+ let lastChanged = -1; -+ const maxLines = Math.max(newDynamicLines.length, this.previousDynamicLines.length); -+ for (let index = 0; index < maxLines; index++) { -+ if ((this.previousDynamicLines[index] ?? "") === (newDynamicLines[index] ?? "")) continue; -+ if (firstChanged === -1) firstChanged = index; -+ lastChanged = index; -+ } -+ -+ const totalLines = stable.lineCount + newDynamicLines.length; -+ const globalCursorPos = cursorPos ? { row: stable.lineCount + cursorPos.row, col: cursorPos.col } : null; -+ if (firstChanged === -1) { -+ this.positionHardwareCursor(globalCursorPos, totalLines); -+ return true; -+ } -+ -+ const firstChangedRow = stable.lineCount + firstChanged; -+ const lastChangedRow = stable.lineCount + lastChanged; -+ if ( -+ firstChangedRow < this.previousViewportTop || -+ (this.getClearOnShrink() && totalLines < this.maxLinesRendered) -+ ) { -+ return false; -+ } -+ for (let index = firstChanged; index <= lastChanged; index++) { -+ if (visibleWidth(newDynamicLines[index] ?? "") > width) return false; -+ } -+ -+ let previousViewportTop = this.previousViewportTop; -+ let viewportTop = previousViewportTop; -+ let hardwareCursorRow = this.hardwareCursorRow; -+ const previousViewportBottom = previousViewportTop + height - 1; -+ const appended = -+ newDynamicLines.length > this.previousDynamicLines.length && -+ firstChanged === this.previousDynamicLines.length && -+ firstChanged > 0; -+ const moveTargetRow = appended ? firstChangedRow - 1 : firstChangedRow; -+ let buffer = "\x1b[?2026h"; -+ if (moveTargetRow > previousViewportBottom) { -+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - viewportTop)); -+ const moveToBottom = height - 1 - currentScreenRow; -+ if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`; -+ const scroll = moveTargetRow - previousViewportBottom; -+ buffer += "\r\n".repeat(scroll); -+ previousViewportTop += scroll; -+ viewportTop += scroll; -+ hardwareCursorRow = moveTargetRow; -+ } -+ -+ const lineDiff = moveTargetRow - viewportTop - (hardwareCursorRow - previousViewportTop); -+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; -+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; -+ buffer += appended ? "\r\n" : "\r"; -+ -+ for (let index = firstChanged; index <= lastChanged; index++) { -+ if (index > firstChanged) buffer += "\r\n"; -+ buffer += `\x1b[2K${newDynamicLines[index] ?? ""}`; -+ } -+ buffer += "\x1b[?2026l"; -+ this.terminal.write(buffer); -+ -+ const finalCursorRow = lastChangedRow; -+ this.cursorRow = Math.max(0, totalLines - 1); -+ this.hardwareCursorRow = finalCursorRow; -+ this.maxLinesRendered = Math.max(this.maxLinesRendered, totalLines); -+ this.previousViewportTop = Math.max(previousViewportTop, finalCursorRow - height + 1); -+ this.positionHardwareCursor(globalCursorPos, totalLines); -+ -+ this.previousLines.length = stable.lineCount; -+ this.previousLines.push(...newDynamicLines); -+ this.previousDynamicLines = newDynamicLines; -+ this.previousHeight = height; -+ return true; -+ } -+ -+ protected doRender(scope: "active" | "full"): void { - if (this.stopped) return; - const width = this.terminal.columns; - const height = this.terminal.rows; -+ if (scope === "active" && this.tryActiveRender(width, height)) return; - const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; - const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; - const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; -@@ -319,6 +457,7 @@ - this.previousViewportTop = Math.max(0, bufferLength - height); - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; -+ this.captureRegionState(width, newLines); - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; -@@ -397,6 +536,7 @@ - // No changes - but still need to update hardware cursor position if it moved - if (firstChanged === -1) { - this.positionHardwareCursor(cursorPos, newLines.length); -+ this.captureRegionState(width, newLines); - this.previousViewportTop = prevViewportTop; - this.previousHeight = height; - return; -@@ -445,6 +585,7 @@ - } - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; -+ this.captureRegionState(width, newLines); - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; -@@ -616,6 +757,7 @@ - this.positionHardwareCursor(cursorPos, newLines.length); - - this.previousLines = newLines; -+ this.captureRegionState(width, newLines); - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; -diff -ruN a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts ---- a/packages/tui/src/tui.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/src/tui.ts 2026-09-12 11:37:33.973568011 +0200 -@@ -319,26 +319,65 @@ - export class Container implements Component { - children: Component[] = []; - private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> }; -+ private regionKind: RenderRegionKind | undefined; -+ private cachedWidth: number | undefined; -+ private cachedLines: string[] | undefined; -+ private currentRevision = 0; -+ -+ get renderRegionKind(): RenderRegionKind | undefined { -+ return this.regionKind; -+ } -+ -+ get renderRegionRevision(): number { -+ return this.currentRevision; -+ } -+ -+ setRenderRegion(kind: RenderRegionKind): this { -+ if (this.regionKind === kind) return this; -+ this.regionKind = kind; -+ this.clearRenderRegionCache(); -+ return this; -+ } -+ -+ getCachedLineCount(width: number): number | undefined { -+ return this.regionKind === "stable" && this.cachedWidth === width ? this.cachedLines?.length : undefined; -+ } -+ -+ expireRenderRegion(): void { -+ if (this.regionKind !== "stable") return; -+ this.clearRenderRegionCache(); -+ } -+ -+ private clearRenderRegionCache(): void { -+ this.cachedWidth = undefined; -+ this.cachedLines = undefined; -+ this.currentRevision += 1; -+ } - - addChild(component: Component): void { - this.children.push(component); -+ this.expireRenderRegion(); - } - - removeChild(component: Component): void { - const index = this.children.indexOf(component); - if (index !== -1) { - this.children.splice(index, 1); -+ this.expireRenderRegion(); - } - } - - clear(): void { -+ const hadChildren = this.children.length > 0; - this.children = []; -+ if (hadChildren) this.expireRenderRegion(); - } - - invalidate(): void { - for (const child of this.children) { - child.invalidate?.(); - } -+ this.expireRenderRegion(); - } - - handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined { -@@ -364,6 +403,9 @@ - } - - render(width: number): string[] { -+ if (this.regionKind === "stable" && this.cachedWidth === width && this.cachedLines) { -+ return this.cachedLines; -+ } - const lines: string[] = []; - const mouseChildren: Array<{ component: Component; height: number }> = []; - for (const child of this.children) { -@@ -374,10 +416,16 @@ - } - } - this.mouseLayout = { width, children: mouseChildren }; -+ if (this.regionKind === "stable") { -+ this.cachedWidth = width; -+ this.cachedLines = lines; -+ } - return lines; - } - } - -+export type RenderRegionKind = "stable" | "dynamic"; -+ - /** - * TUI - Main class for managing terminal UI with differential rendering - */ -@@ -443,6 +491,7 @@ - stop(options?: TuiStopOptions): void; - renderNow(force?: boolean): void; - requestRender(force?: boolean): void; -+ requestActiveRender?(): void; - addInputListener(listener: TuiInputListener): () => void; - removeInputListener(listener: TuiInputListener): void; - onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; -@@ -471,6 +520,7 @@ - /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ - public onDebug?: () => void; - private renderRequested = false; -+ private renderScope: "active" | "full" = "active"; - private immediateRenderScheduled = false; - private renderTimer: NodeJS.Timeout | undefined; - private lastRenderAt = 0; -@@ -505,7 +555,7 @@ - } - } - -- protected abstract doRender(): void; -+ protected abstract doRender(scope: "active" | "full"): void; - - protected resetRenderState(): void {} - -@@ -875,6 +925,22 @@ - for (const overlay of this.overlayStack) overlay.component.invalidate(); - } - -+ private expireStableRegions(): void { -+ const visit = (component: Component): void => { -+ if (component instanceof Container) { -+ component.expireRenderRegion(); -+ for (const child of component.children) visit(child); -+ } -+ }; -+ for (const root of this.getMountedRoots()) visit(root); -+ } -+ -+ private consumeRenderScope(): "active" | "full" { -+ const scope = this.renderScope; -+ this.renderScope = "active"; -+ return scope; -+ } -+ - start(): void { - this.stopped = false; - this.beforeTerminalStart(); -@@ -942,14 +1008,18 @@ - } - - renderNow(force = false): void { -+ this.expireStableRegions(); - if (force) this.resetRenderState(); - this.renderRequested = false; -+ this.renderScope = "active"; - this.cancelRenderTimer(); - this.lastRenderAt = performance.now(); -- this.doRender(); -+ this.doRender("full"); - } - - requestRender(force = false): void { -+ this.expireStableRegions(); -+ this.renderScope = "full"; - if (force) { - this.resetRenderState(); - this.requestImmediateRender(); -@@ -960,6 +1030,12 @@ - process.nextTick(() => this.scheduleRender()); - } - -+ requestActiveRender(): void { -+ if (this.renderRequested) return; -+ this.renderRequested = true; -+ process.nextTick(() => this.scheduleRender()); -+ } -+ - private requestImmediateRender(): void { - this.cancelRenderTimer(); - this.renderRequested = true; -@@ -973,7 +1049,7 @@ - this.cancelRenderTimer(); - this.renderRequested = false; - this.lastRenderAt = performance.now(); -- this.doRender(); -+ this.doRender(this.consumeRenderScope()); - }); - } - -@@ -996,7 +1072,7 @@ - } - this.renderRequested = false; - this.lastRenderAt = performance.now(); -- this.doRender(); -+ this.doRender(this.consumeRenderScope()); - if (this.renderRequested) { - this.scheduleRender(); - } -diff -ruN a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts ---- a/packages/tui/test/layout.test.ts 1970-01-01 01:00:01.000000000 +0100 -+++ b/packages/tui/test/layout.test.ts 2026-09-12 11:36:03.921905712 +0200 -@@ -68,6 +68,21 @@ - assert.deepStrictEqual(visibleLines(frame.lines), ["visible 1", "visible 2", "visible 3"]); - }); - -+ it("does not flatten nested layout content after measuring a scroll view", () => { -+ let documentRenderCount = 0; -+ class CountingVStack extends VStack { -+ override render(width: number): string[] { -+ documentRenderCount += 1; -+ return super.render(width); -+ } -+ } -+ const document = new CountingVStack([new Text("one\ntwo\nthree", 0, 0), new Text("four\nfive\nsix", 0, 0)]); -+ -+ renderLayoutFrame(new ScrollView(document, { follow: "end" }), 10, 3, () => {}); -+ -+ assert.strictEqual(documentRenderCount, 0); -+ }); -+ - it("shrinks entries to their minimum sizes", () => { - const frame = renderLayoutFrame( - new VStack([ -@@ -145,6 +160,22 @@ - assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1")); - }); - -+ it("crops a nested Kitty image whose first line is above the viewport", () => { -+ const imageId = 125; -+ const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); -+ registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); -+ const image = { -+ render: () => [imageLine, "", ""], -+ invalidate: () => {}, -+ }; -+ const document = new VStack([new Text("one\ntwo", 0, 0), image, new Text("after\nend", 0, 0)]); -+ -+ const frame = renderLayoutFrame(new ScrollView(document, { follow: "end" }), 20, 3, () => {}); -+ -+ assert.ok(frame.lines[0]?.includes("i=125")); -+ assert.ok(frame.lines[0]?.includes("y=66,h=34,r=1")); -+ }); -+ - it("composes horizontal children at allocated widths", () => { - const frame = renderLayoutFrame( - new HStack([ -diff -ruN a/packages/tui/test/render-region.test.ts b/packages/tui/test/render-region.test.ts ---- a/packages/tui/test/render-region.test.ts 1970-01-01 01:00:00.000000000 +0100 -+++ b/packages/tui/test/render-region.test.ts 2026-09-12 11:36:03.921983265 +0200 -@@ -0,0 +1,293 @@ -+import assert from "node:assert"; -+import { describe, it } from "node:test"; -+import { ScrollView } from "../src/components/scroll-view.ts"; -+import { Text } from "../src/components/text.ts"; -+import { VStack } from "../src/components/v-stack.ts"; -+import { renderLayoutFrame } from "../src/layout.ts"; -+import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts"; -+import { type Component, Container } from "../src/tui.ts"; -+import { TuiAltScreen } from "../src/tui-alt-screen.ts"; -+import { TuiMainScreen } from "../src/tui-main-screen.ts"; -+import { VirtualTerminal } from "./virtual-terminal.ts"; -+ -+class CountingComponent implements Component { -+ renderCount = 0; -+ lines: string[]; -+ -+ constructor(lines: string[]) { -+ this.lines = lines; -+ } -+ -+ render(): string[] { -+ this.renderCount += 1; -+ return this.lines; -+ } -+ -+ invalidate(): void {} -+} -+ -+class ResetCountingTui extends TuiMainScreen { -+ resetLineCount = 0; -+ -+ protected override applyLineResets(lines: string[]): string[] { -+ this.resetLineCount += lines.length; -+ return super.applyLineResets(lines); -+ } -+} -+ -+function createRenderRegion(kind: "stable" | "dynamic", children: readonly Component[]): Container { -+ const region = new Container(); -+ for (const child of children) region.addChild(child); -+ return region.setRenderRegion(kind); -+} -+ -+describe("RenderRegion", () => { -+ it("drops cached output when a region changes kind", () => { -+ const content = new CountingComponent(["before"]); -+ const region = createRenderRegion("stable", [content]); -+ -+ assert.deepStrictEqual(region.render(20), ["before"]); -+ content.lines = ["after"]; -+ region.setRenderRegion("dynamic"); -+ -+ assert.strictEqual(region.getCachedLineCount(20), undefined); -+ assert.deepStrictEqual(region.render(20), ["after"]); -+ assert.strictEqual(content.renderCount, 2); -+ }); -+ -+ it("preserves Container.clear array replacement for an empty region", () => { -+ const region = new Container(); -+ const originalChildren = region.children; -+ -+ region.clear(); -+ -+ assert.notStrictEqual(region.children, originalChildren); -+ }); -+ -+ it("keeps stable transcript work out of active main-screen renders", async () => { -+ const terminal = new VirtualTerminal(40, 8); -+ const tui = new ResetCountingTui(terminal); -+ const stableContent = new CountingComponent( -+ Array.from({ length: 10_000 }, (_, index) => `stable transcript line ${index}`), -+ ); -+ const stableRegion = createRenderRegion("stable", [stableContent]); -+ const dynamicContent = new Text("working 0", 0, 0); -+ -+ tui.addChild(stableRegion); -+ tui.addChild(dynamicContent); -+ tui.start(); -+ await terminal.waitForRender(); -+ -+ const stableRenderCount = stableContent.renderCount; -+ const resetLineCount = tui.resetLineCount; -+ dynamicContent.setText("working 1"); -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ -+ assert.strictEqual(stableContent.renderCount, stableRenderCount); -+ assert.ok(tui.resetLineCount - resetLineCount <= 1, "active render should reset only dynamic lines"); -+ tui.stop(); -+ }); -+ -+ it("keeps parameterless requestRender conservative for stable content changes", async () => { -+ const terminal = new VirtualTerminal(40, 4); -+ const tui = new TuiMainScreen(terminal); -+ const stableContent = new CountingComponent(["before"]); -+ const stableRegion = createRenderRegion("stable", [stableContent]); -+ -+ tui.addChild(stableRegion); -+ tui.start(); -+ await terminal.waitForRender(); -+ const renderCount = stableContent.renderCount; -+ -+ stableContent.lines = ["after"]; -+ tui.requestRender(); -+ await terminal.waitForRender(); -+ -+ assert.strictEqual(stableContent.renderCount, renderCount + 1); -+ assert.match(terminal.getViewport().join("\n"), /after/); -+ tui.stop(); -+ }); -+ -+ it("does not let active requests downgrade a queued full invalidation", async () => { -+ const terminal = new VirtualTerminal(40, 4); -+ const tui = new TuiMainScreen(terminal); -+ const stableContent = new CountingComponent(["before"]); -+ tui.addChild(createRenderRegion("stable", [stableContent])); -+ tui.start(); -+ await terminal.waitForRender(); -+ -+ stableContent.lines = ["full then active"]; -+ tui.requestRender(); -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ assert.match(terminal.getViewport().join("\n"), /full then active/); -+ -+ stableContent.lines = ["active then full"]; -+ tui.requestActiveRender(); -+ tui.requestRender(); -+ await terminal.waitForRender(); -+ assert.match(terminal.getViewport().join("\n"), /active then full/); -+ assert.strictEqual(stableContent.renderCount, 3); -+ tui.stop(); -+ }); -+ -+ it("preserves the viewport while active content appends", async () => { -+ const terminal = new VirtualTerminal(24, 4); -+ const tui = new TuiMainScreen(terminal); -+ const stableContent = new CountingComponent(Array.from({ length: 20 }, (_, index) => `stable ${index}`)); -+ const dynamicContent = new CountingComponent(["working"]); -+ -+ tui.addChild(createRenderRegion("stable", [stableContent])); -+ tui.addChild(createRenderRegion("dynamic", [dynamicContent])); -+ tui.start(); -+ await terminal.waitForRender(); -+ const redraws = tui.fullRedraws; -+ -+ dynamicContent.lines = ["working", "done"]; -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ -+ assert.strictEqual(tui.fullRedraws, redraws); -+ assert.deepStrictEqual(terminal.getViewport(), ["stable 18", "stable 19", "working", "done"]); -+ tui.stop(); -+ }); -+ -+ it("falls back safely when active content shrinks", async () => { -+ const terminal = new VirtualTerminal(24, 4); -+ const tui = new ResetCountingTui(terminal); -+ const stableContent = new CountingComponent(Array.from({ length: 8 }, (_, index) => `stable ${index}`)); -+ const dynamicContent = new CountingComponent(["working", "done"]); -+ -+ tui.addChild(createRenderRegion("stable", [stableContent])); -+ tui.addChild(createRenderRegion("dynamic", [dynamicContent])); -+ tui.start(); -+ await terminal.waitForRender(); -+ const resetLineCount = tui.resetLineCount; -+ -+ dynamicContent.lines = ["done"]; -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ -+ assert.ok(tui.resetLineCount - resetLineCount > stableContent.lines.length); -+ assert.ok(terminal.getViewport().some((line) => line.includes("done"))); -+ assert.ok(terminal.getViewport().every((line) => !line.includes("working"))); -+ tui.stop(); -+ }); -+ -+ it("falls back safely for active Kitty images", async () => { -+ const terminal = new VirtualTerminal(24, 4); -+ const tui = new ResetCountingTui(terminal); -+ const stableContent = new CountingComponent(Array.from({ length: 8 }, (_, index) => `stable ${index}`)); -+ const dynamicContent = new CountingComponent(["working"]); -+ const imageId = 931; -+ const imageLine = encodeKitty("AAAA", { columns: 2, rows: 2, imageId, moveCursor: false }); -+ registerKittyImageMetadata({ imageId, columns: 2, rows: 2, widthPx: 100, heightPx: 100 }); -+ -+ tui.addChild(createRenderRegion("stable", [stableContent])); -+ tui.addChild(createRenderRegion("dynamic", [dynamicContent])); -+ tui.start(); -+ await terminal.waitForRender(); -+ const resetLineCount = tui.resetLineCount; -+ -+ dynamicContent.lines = [imageLine, ""]; -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ -+ assert.ok(tui.resetLineCount - resetLineCount > stableContent.lines.length); -+ assert.ok(terminal.getViewport().every((line) => !line.includes("working"))); -+ tui.stop(); -+ }); -+ -+ it("falls back safely while an overlay is visible", async () => { -+ const terminal = new VirtualTerminal(24, 6); -+ const tui = new ResetCountingTui(terminal); -+ const stableContent = new CountingComponent(Array.from({ length: 8 }, (_, index) => `stable ${index}`)); -+ const dynamicContent = new Text("working 0", 0, 0); -+ -+ tui.addChild(createRenderRegion("stable", [stableContent])); -+ tui.addChild(createRenderRegion("dynamic", [dynamicContent])); -+ tui.start(); -+ await terminal.waitForRender(); -+ const overlay = tui.showOverlay(new Text("OVERLAY", 0, 0), { anchor: "top-left", width: 10 }); -+ await terminal.waitForRender(); -+ const resetLineCount = tui.resetLineCount; -+ -+ dynamicContent.setText("working 1"); -+ tui.requestActiveRender(); -+ await terminal.waitForRender(); -+ -+ assert.ok(tui.resetLineCount - resetLineCount > stableContent.lines.length); -+ assert.ok(terminal.getViewport().some((line) => line.includes("OVERLAY"))); -+ assert.ok(terminal.getViewport().some((line) => line.includes("working 1"))); -+ overlay.hide(); -+ tui.stop(); -+ }); -+ -+ it("promotes dynamic children without redrawing unchanged transcript content", async () => { -+ const terminal = new VirtualTerminal(24, 4); -+ const tui = new TuiMainScreen(terminal); -+ const stableContainer = new Container(); -+ const dynamicContainer = new Container(); -+ const stableRegion = createRenderRegion("stable", [stableContainer]); -+ const dynamicRegion = createRenderRegion("dynamic", [dynamicContainer]); -+ const stableLine = new Text("stable", 0, 0); -+ const dynamicLine = new Text("dynamic", 0, 0); -+ stableContainer.addChild(stableLine); -+ dynamicContainer.addChild(dynamicLine); -+ -+ tui.addChild(stableRegion); -+ tui.addChild(dynamicRegion); -+ tui.start(); -+ await terminal.waitForRender(); -+ const redraws = tui.fullRedraws; -+ const viewport = terminal.getViewport(); -+ -+ stableContainer.addChild(dynamicLine); -+ dynamicContainer.removeChild(dynamicLine); -+ stableRegion.expireRenderRegion(); -+ tui.requestRender(); -+ await terminal.waitForRender(); -+ -+ assert.strictEqual(tui.fullRedraws, redraws); -+ assert.deepStrictEqual(terminal.getViewport(), viewport); -+ tui.stop(); -+ }); -+ -+ it("reuses stable region output in fullscreen layout frames", () => { -+ const stableContent = new CountingComponent( -+ Array.from({ length: 10_000 }, (_, index) => `stable transcript line ${index}`), -+ ); -+ const stableRegion = createRenderRegion("stable", [stableContent]); -+ const dynamicContent = new Text("working 0", 0, 0); -+ const document = new VStack([stableRegion, dynamicContent]); -+ const viewport = new ScrollView(document, { follow: "end" }); -+ -+ renderLayoutFrame(viewport, 40, 8, () => {}); -+ const stableRenderCount = stableContent.renderCount; -+ dynamicContent.setText("working 1"); -+ renderLayoutFrame(viewport, 40, 8, () => {}); -+ -+ assert.strictEqual(stableContent.renderCount, stableRenderCount); -+ }); -+ -+ it("invalidates stable region output on fullscreen full renders", async () => { -+ const terminal = new VirtualTerminal(24, 4); -+ const tui = new TuiAltScreen(terminal); -+ const stableContent = new CountingComponent(["before"]); -+ const stableRegion = createRenderRegion("stable", [stableContent]); -+ const document = new VStack([stableRegion, new Text("dynamic", 0, 0)]); -+ tui.setLayoutRoot(new ScrollView(document, { follow: "end" })); -+ tui.start(); -+ await terminal.waitForRender(); -+ const renderCount = stableContent.renderCount; -+ -+ stableContent.lines = ["after"]; -+ tui.requestRender(); -+ await terminal.waitForRender(); -+ -+ assert.strictEqual(stableContent.renderCount, renderCount + 1); -+ assert.ok(terminal.getViewport().some((line) => line.includes("after"))); -+ tui.stop(); -+ }); -+});