Sfoglia il codice sorgente

perf(tui): incremental step timing and card render caches for long sessions

Resuming a long session (196k events, 2.2k steps, 1.8k tool cards) took
~12s to render and ~800ms to echo one keystroke:

- Every step's timing footer called stepTimingAt, which replayed the whole
  event log per footer - O(steps x events) on the initial render.
- pi-tui re-renders every component each frame and relies on per-component
  line caches, but ToolCardComponent/ContextCardComponent built throwaway
  Text/Markdown instances inside render(width), re-wrapping every settled
  card's output on every keystroke.

Replace the per-footer replay with one shared StepTimingTracker per chat
mount (single O(events) cursor over the append-only log), and cache card
rows by width via CardLineCache, dropped by every state mutator and
invalidate().

Measured (tmux 200x50, 196k-event session): resume prompt-ready ~12s -> ~7.6s;
per-keystroke echo ~800ms median -> ~11ms.
Turtle 1 mese fa
parent
commit
5329bef04d

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md
+2026-08-03-tui-long-session-render-costs.md: c5b03960b6951cb2de2b847f03ec8eb2b92cc55c
+2026-08-03-tui-long-session-render-costs.zh.md: b41c5a8c546e296525645d82808117673fdeec6d

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md

@@ -0,0 +1,33 @@
+# Agent Note: TUI long-session render costs — shared step-timing scan and card line caches
+
+Status: implemented
+
+English | [中文](2026-08-03-tui-long-session-render-costs.zh.md)
+
+## Problem
+
+On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s):
+
+- Every step's timing footer called `stepTimingAt`, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU.
+- pi-tui re-renders every component each frame and relies on per-component line caches (its own `Text`/`Markdown` cache by `(text, width)`). `ToolCardComponent.render()` and `ContextCardComponent.render()` built throwaway `new Text(...)`/`new Markdown(...)` instances inside `render(width)`, so every frame — every keystroke — re-wrapped every settled card's output.
+
+## Decision
+
+`packages/ui/tui/src/chat/timing.ts` replaces `stepTimingAt` with `StepTimingTracker`: one accumulator per chat mount, created in `createTuiChat` and threaded through `StreamingAssistantComponent` into each `StepTimingComponent`. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its `step/end`. The tracker requires the append-only session log (the `seq = log length` contract).
+
+`ToolCardComponent` and `ContextCardComponent` cache their rendered rows keyed by width. The cache drops on every state mutator (`updateResult`, `setVisibility`, `setExpanded`) and on `invalidate()` (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit `cachedWidth`/`cachedLines` where rendering is custom, e.g. pi `coding-agent` `bash.ts`), which the imperative `render(width)` bodies here had silently defeated.
+
+Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity).
+
+## Alternatives considered
+
+- **Index `step/start` offsets, keep per-footer replay** — removes the `findIndex` but each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping.
+- **Restructure the cards into persistent pi-tui child components** (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache.
+- **Cache inside pi-tui's `Container.render`** — wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream.
+
+## Consequences
+
+- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear.
+- The tracker consumes event times as logged and drops the removed implementation's mid-scan `time > at` cutoff, which per-footer `at` values make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals.
+- Card `render()` is no longer a pure function of `(state, width)` per call — mutators must drop `linesCache`. A new mutator that forgets to do so shows stale rows; the cache tests in `packages/ui/tui/tests/transcript-card-cache.spec.ts` pin the contract for the existing mutators.
+- `StepTimingTracker` assumes step coordinates are not reused after `step/end`; a duplicate `step/start` for a closed step is ignored rather than restarting the step.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存
+
+Status: implemented
+
+[English](2026-08-03-tui-long-session-render-costs.md) | 中文
+
+## 问题
+
+在一个恢复后的长会话(196k 条事件、2.2k 个步骤、1.8k 张工具卡片)中,TUI 渲染 transcript(文本记录)耗时约 12 秒,回显一次按键耗时约 800 毫秒。性能剖析表明,两项耗时都来自渲染路径,而非会话加载(zstd + 解析 + 表层播种约为 1.7 秒):
+
+- 每个步骤的耗时页脚都会调用 `stepTimingAt`,而它会针对每个页脚从索引 0 起回放整个事件日志,因此初次渲染的复杂度为 O(步骤数 × 事件数),占用约 6 秒 CPU 时间。
+- pi-tui 每一帧都会重新渲染所有组件,并依赖各组件自己的行缓存(它的 `Text`/`Markdown` 会按 `(text, width)` 缓存)。`ToolCardComponent.render()` 和 `ContextCardComponent.render()` 构造用后即弃的 `new Text(...)`/`new Markdown(...)` 实例,且构造发生在 `render(width)` 内,因此每一帧,也就是每次按键,都会重新对每张已结算卡片的输出进行折行。
+
+## 决策
+
+`packages/ui/tui/src/chat/timing.ts` 不再使用 `stepTimingAt`,改用 `StepTimingTracker`:每次挂载聊天界面时在 `createTuiChat` 中创建一个累加器,再经 `StreamingAssistantComponent` 传入每个 `StepTimingComponent`。每次查询都会推进游标,扫描上次查询后追加的事件,并在一个映射表中保存各步骤的 bucket 状态,因此所有页脚合计只需 O(事件数)。查询时,系统把未闭合 bucket 累加到查询时刻;步骤在其 `step/end` 处固定。该跟踪器要求会话日志仅追加,即遵守 `seq = log length` 契约。
+
+`ToolCardComponent` 和 `ContextCardComponent` 按宽度键控缓存渲染行。调用任一状态修改方法(`updateResult`、`setVisibility`、`setExpanded`)或 `invalidate()`(pi-tui 的全树级联)时会清空缓存,因此状态变化一定会重新渲染;其他情况,包括每一次按键帧,都会返回缓存行。这恢复了上游 pi 自身的组件惯例:使用常驻子组件;自定义渲染时显式使用 `cachedWidth`/`cachedLines`,例如 pi `coding-agent` 的 `bash.ts`。而这里命令式的 `render(width)` 函数体此前让这套惯例失效。
+
+在该 196k 条事件的会话上测得(tmux,200×50):恢复后提示符就绪耗时从 12.2 秒降至 7.2 秒;每次按键的回显耗时中位数从 796 毫秒降至 17 毫秒(与新会话持平)。
+
+## 曾考虑的替代方案
+
+- **索引 `step/start` 偏移量,保留逐页脚回放**:这会消除 `findIndex`,但每个页脚仍要从共享数组扫描所属步骤的区间;跟踪器的一次共享遍历以更少的额外状态记录取得相同的复杂度改进。
+- **把卡片重构为常驻 pi-tui 子组件**(上游 pi 的主要风格):稳定状态下成本相同,但卡片状态处理所需改动更大,相较按宽度键控的缓存并无额外收益。
+- **在 pi-tui 的 `Container.render` 内缓存**:层级不对:对第三方内嵌代码的补丁范围会扩大,而上游已经约定由组件拥有各自的缓存。
+
+## 后果
+
+- 输入延迟不再随工具输出总量增长;剩余的每帧成本是 pi-tui 的树遍历与行拼接,与渲染行数呈线性关系。恢复时的渲染成本现由 pi-tui 的一次性初始布局(196k 条事件时约 4 秒)与加载(约 1.7 秒)主导,两者均为线性。
+- 该跟踪器直接采用日志记录的事件时间,不再像已移除的实现那样,在扫描中途遇到 `time > at` 时截断;由于每个页脚的 `at` 值不同,共享扫描无法采用这种截断;挂钟时间倒退时,每个 bucket 都以零为下限,所得总计值可能与旧截断下的总计值不同。
+- 卡片的 `render()` 不再是每次调用时 `(state, width)` 的纯函数,状态修改方法必须清空 `linesCache`。若新增状态修改方法时忘记清空,界面会显示陈旧行;`packages/ui/tui/tests/transcript-card-cache.spec.ts` 中的缓存测试固定了现有状态修改方法的契约。
+- `StepTimingTracker` 假定步骤坐标在 `step/end` 后不会复用;对已关闭步骤重复出现的 `step/start` 会被忽略,不会重新启动该步骤。

+ 1 - 1
docs/cordis-catalog/services.md

@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
 abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
 abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
 ```
 ```
 
 
-Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:246`](../../packages/ui/tui/src/index.ts)
 
 
 ## `ctx.typert` — `TypertRegistry`
 ## `ctx.typert` — `TypertRegistry`
 
 

+ 49 - 23
packages/ui/tui/src/chat/timing.ts

@@ -132,32 +132,58 @@ function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
   return totals
   return totals
 }
 }
 
 
+function stepKey(position: StepPosition): string {
+  return `${position.turn}:${position.step}`
+}
+
+interface TrackedStep extends TimingState {
+  /** Set at the step's `step/end`; later same-coordinate events no longer advance the step. */
+  closed: boolean
+}
+
 /**
 /**
- * Replay one step's accumulated per-phase timing up to clock `at`.
- * @param events - Session events to replay.
- * @param position - Turn/step coordinates of the step.
- * @param at - Render clock to accumulate the open bucket up to.
- * @returns The step's per-phase totals.
+ * Incremental per-step timing accumulator shared by every step's timing footer
+ * in one transcript. One forward pass over the append-only session log serves
+ * all steps' totals: each query advances a cursor over the events appended
+ * since the previous query, so a transcript of S steps costs O(events) in
+ * total instead of the O(S × events) of replaying the whole log per footer
+ * ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
+ *
+ * The log must be append-only with stable indices (the session `seq = log
+ * length` contract). Event times are consumed as logged: a backward wall-clock
+ * step clamps each bucket at zero rather than cutting the scan off at the
+ * query clock. The open bucket is accumulated to the query clock at lookup,
+ * never during the scan.
  */
  */
-export function stepTimingAt(
-  events: readonly SessionEvent[],
-  position: StepPosition,
-  at: number,
-): TimingTotals {
-  const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
-  if (startIndex < 0) return emptyTimingTotals()
-  const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
-  const state = timingState(start.time)
-  for (let index = startIndex + 1; index < events.length; index += 1) {
-    const event = events[index] as SessionEvent
-    if (event.time > at) break
-    if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
-      && sameStep(event, position)) {
-      advanceStepTiming(state, event)
-      if (event.type === 'step/end') break
+export class StepTimingTracker {
+  private scanned = 0
+  private readonly steps = new Map<string, TrackedStep>()
+
+  /**
+   * Advance over events appended since the previous query, then return one
+   * step's accumulated per-phase timing up to clock `at`.
+   * @param events - Current session event log (append-only).
+   * @param position - Turn/step coordinates of the queried step.
+   * @param at - Render clock to accumulate the open bucket up to.
+   * @returns The step's per-phase totals; empty when the step never started.
+   */
+  totalsAt(events: readonly SessionEvent[], position: StepPosition, at: number): TimingTotals {
+    for (; this.scanned < events.length; this.scanned += 1) {
+      const event = events[this.scanned] as SessionEvent
+      if (event.type === 'step/start') {
+        const key = stepKey(event.data)
+        if (!this.steps.has(key)) this.steps.set(key, { ...timingState(event.time), closed: false })
+      } else if (event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') {
+        const state = this.steps.get(stepKey(event.data))
+        if (state !== undefined && !state.closed) {
+          advanceStepTiming(state, event)
+          if (event.type === 'step/end') state.closed = true
+        }
+      }
     }
     }
+    const state = this.steps.get(stepKey(position))
+    return state === undefined ? emptyTimingTotals() : timingTotalsAt(state, at)
   }
   }
-  return timingTotalsAt(state, at)
 }
 }
 
 
 /**
 /**
@@ -191,7 +217,7 @@ const COMPACTING_GLYPH = '⊙'
 /**
 /**
  * Derive the currently open step's active timing bucket, or `undefined` when no
  * Derive the currently open step's active timing bucket, or `undefined` when no
  * step is open. The open step is the last `step/start` with no later matching
  * step is open. The open step is the last `step/start` with no later matching
- * `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
+ * `step/end`; its bucket is replayed with the same rules as {@link StepTimingTracker}.
  * @param events - Session events to scan.
  * @param events - Session events to scan.
  * @returns The open step's active bucket, or `undefined`.
  * @returns The open step's active bucket, or `undefined`.
  */
  */

+ 51 - 12
packages/ui/tui/src/components/transcript.ts

@@ -33,8 +33,8 @@ import { contentText, type ParsedArguments } from './content.ts'
 import {
 import {
   formatCompletionTime,
   formatCompletionTime,
   formatTimingTotals,
   formatTimingTotals,
-  stepTimingAt,
   type StepPosition,
   type StepPosition,
+  type StepTimingTracker,
 } from '../chat/timing.ts'
 } from '../chat/timing.ts'
 
 
 /** Concatenate the text of every block of one type, separated by blank lines. */
 /** Concatenate the text of every block of one type, separated by blank lines. */
@@ -228,6 +228,7 @@ class StepTimingComponent extends Container {
   constructor(
   constructor(
     private readonly position: StepPosition,
     private readonly position: StepPosition,
     private readonly events: () => readonly SessionEvent[],
     private readonly events: () => readonly SessionEvent[],
+    private readonly tracker: StepTimingTracker,
     private readonly now: () => number,
     private readonly now: () => number,
     private readonly palette: Palette,
     private readonly palette: Palette,
   ) {
   ) {
@@ -247,7 +248,7 @@ class StepTimingComponent extends Container {
 
 
   private rebuild(): void {
   private rebuild(): void {
     this.clear()
     this.clear()
-    const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
+    const totals = this.tracker.totalsAt(this.events(), this.position, this.completionTime ?? this.now())
     const timing = formatTimingTotals(totals, true)
     const timing = formatTimingTotals(totals, true)
     const header = this.completionTime === undefined
     const header = this.completionTime === undefined
       ? timing
       ? timing
@@ -277,13 +278,14 @@ export class StreamingAssistantComponent extends Container {
     /** The step's turn/step coordinates, used to group steps into their turn. */
     /** The step's turn/step coordinates, used to group steps into their turn. */
     readonly position: StepPosition,
     readonly position: StepPosition,
     events: () => readonly SessionEvent[],
     events: () => readonly SessionEvent[],
+    tracker: StepTimingTracker,
     now: () => number,
     now: () => number,
     private showReasoning: boolean,
     private showReasoning: boolean,
     private readonly palette: Palette,
     private readonly palette: Palette,
     private readonly mdTheme: MarkdownTheme,
     private readonly mdTheme: MarkdownTheme,
   ) {
   ) {
     super()
     super()
-    this.timing = new StepTimingComponent(position, events, now, palette)
+    this.timing = new StepTimingComponent(position, events, tracker, now, palette)
     this.rebuild()
     this.rebuild()
   }
   }
 
 
@@ -409,8 +411,43 @@ interface CardBody {
  */
  */
 export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
 export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
 
 
+/**
+ * Transcript card with a width-keyed rendered-row cache. pi-tui re-renders
+ * every component each frame and relies on per-component line caches (its own
+ * `Text`/`Markdown` do this); a card that rebuilds rows inside `render(width)`
+ * would re-wrap its output every frame
+ * ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
+ * Subclasses render through {@link renderLines} and call {@link dropLines}
+ * from every state mutator; with `invalidate()` (pi-tui's tree-wide cascade)
+ * also dropping, a state change always re-renders.
+ */
+abstract class CachedCardComponent implements Component {
+  private cached: { width: number; lines: string[] } | undefined
+
+  /** Discard the cached rows so the next render recomputes them. */
+  protected dropLines(): void {
+    this.cached = undefined
+  }
+
+  invalidate(): void {
+    this.cached = undefined
+  }
+
+  render(width: number): string[] {
+    if (this.cached?.width !== width) this.cached = { width, lines: this.renderLines(width) }
+    return this.cached.lines
+  }
+
+  /**
+   * Render the card's rows for `width` without caching.
+   * @param width - Render width the rows are wrapped to.
+   * @returns The card's rows.
+   */
+  protected abstract renderLines(width: number): string[]
+}
+
 /** A tool call and its result, rendered as a collapsible status card. */
 /** A tool call and its result, rendered as a collapsible status card. */
-export class ToolCardComponent implements Component {
+export class ToolCardComponent extends CachedCardComponent {
   private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
   private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
   private visibility: ToolCardVisibility = 'collapsed'
   private visibility: ToolCardVisibility = 'collapsed'
   private callView: ToolCallView
   private callView: ToolCallView
@@ -426,6 +463,7 @@ export class ToolCardComponent implements Component {
     private readonly palette: Palette,
     private readonly palette: Palette,
     private readonly mdTheme: MarkdownTheme,
     private readonly mdTheme: MarkdownTheme,
   ) {
   ) {
+    super()
     this.callView = this.presentCall()
     this.callView = this.presentCall()
   }
   }
 
 
@@ -447,6 +485,7 @@ export class ToolCardComponent implements Component {
    */
    */
   updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
   updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
     this.diffBodyCache = undefined
     this.diffBodyCache = undefined
+    this.dropLines()
     const result = event.message.content[0]
     const result = event.message.content[0]
     this.result = {
     this.result = {
       content: [...result.content],
       content: [...result.content],
@@ -469,11 +508,10 @@ export class ToolCardComponent implements Component {
    */
    */
   setVisibility(visibility: ToolCardVisibility): void {
   setVisibility(visibility: ToolCardVisibility): void {
     this.visibility = visibility
     this.visibility = visibility
+    this.dropLines()
   }
   }
 
 
-  invalidate(): void {}
-
-  render(width: number): string[] {
+  protected renderLines(width: number): string[] {
     // Hidden renders nothing — not even the leading gap — so the transcript
     // Hidden renders nothing — not even the leading gap — so the transcript
     // keeps only the conversation, the way Codex hides tool calls.
     // keeps only the conversation, the way Codex hides tool calls.
     if (this.visibility === 'hidden') return []
     if (this.visibility === 'hidden') return []
@@ -725,7 +763,7 @@ function stripReminderFrame(text: string): string {
  * well-formed XML, which made both the fold and the frame-line suppression
  * well-formed XML, which made both the fold and the frame-line suppression
  * content-dependent.
  * content-dependent.
  */
  */
-export class ContextCardComponent implements Component {
+export class ContextCardComponent extends CachedCardComponent {
   private expanded = false
   private expanded = false
 
 
   constructor(
   constructor(
@@ -733,7 +771,9 @@ export class ContextCardComponent implements Component {
     private readonly text: string,
     private readonly text: string,
     private readonly maxOutputLines: number,
     private readonly maxOutputLines: number,
     private readonly palette: Palette,
     private readonly palette: Palette,
-  ) {}
+  ) {
+    super()
+  }
 
 
   /**
   /**
    * Expand or collapse the card body.
    * Expand or collapse the card body.
@@ -741,11 +781,10 @@ export class ContextCardComponent implements Component {
    */
    */
   setExpanded(expanded: boolean): void {
   setExpanded(expanded: boolean): void {
     this.expanded = expanded
     this.expanded = expanded
+    this.dropLines()
   }
   }
 
 
-  invalidate(): void {}
-
-  render(width: number): string[] {
+  protected renderLines(width: number): string[] {
     const header = this.palette.dim(`Context · ${displayText(this.label)}`)
     const header = this.palette.dim(`Context · ${displayText(this.label)}`)
     // Emptiness is decided on the stripped text: styling a blank body would yield
     // Emptiness is decided on the stripped text: styling a blank body would yield
     // one escape-only row, which reads as a stray blank line under the header.
     // one escape-only row, which reads as a stray blank line under the header.

+ 5 - 0
packages/ui/tui/src/index.ts

@@ -88,6 +88,7 @@ import {
   runningPhaseGlyph,
   runningPhaseGlyph,
   STATUS_ANIMATION_INTERVAL_MS,
   STATUS_ANIMATION_INTERVAL_MS,
   STATUS_FADE_MS,
   STATUS_FADE_MS,
+  StepTimingTracker,
   TIMING_BUCKET_GLYPHS,
   TIMING_BUCKET_GLYPHS,
   type StepPosition,
   type StepPosition,
 } from './chat/timing.ts'
 } from './chat/timing.ts'
@@ -358,6 +359,9 @@ export function createTuiChat(
   let toolsVisibility: ToolCardVisibility = 'collapsed'
   let toolsVisibility: ToolCardVisibility = 'collapsed'
   let streaming: StreamingAssistantComponent | undefined
   let streaming: StreamingAssistantComponent | undefined
   let completedStreaming: StreamingAssistantComponent | undefined
   let completedStreaming: StreamingAssistantComponent | undefined
+  // One shared accumulator serves every step's timing footer; per-footer
+  // replay of the whole log is quadratic on a long resumed session.
+  const stepTimingTracker = new StepTimingTracker()
   // Assistant step components in model order per turn, for hidden-mode folding:
   // Assistant step components in model order per turn, for hidden-mode folding:
   // with tool cards hidden, a turn keeps one Assistant header and later steps
   // with tool cards hidden, a turn keeps one Assistant header and later steps
   // render as headerless continuations (see applyTurnFolding).
   // render as headerless continuations (see applyTurnFolding).
@@ -769,6 +773,7 @@ export function createTuiChat(
     streaming = new StreamingAssistantComponent(
     streaming = new StreamingAssistantComponent(
       position,
       position,
       () => agent.session.events,
       () => agent.session.events,
+      stepTimingTracker,
       now,
       now,
       showReasoning,
       showReasoning,
       palette,
       palette,

+ 90 - 0
packages/ui/tui/tests/timing-tracker.spec.ts

@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { StepTimingTracker } from '../src/chat/timing.ts'
+
+/** One completed two-phase step plus a tool call, in event-log order. */
+function stepEvents(turn: number, step: number, base: number, seq: number): SessionEvent[] {
+  return [
+    { type: 'step/start', seq: seq, time: base, data: { turn, step } },
+    { type: 'assistant/chunk', seq: seq + 1, time: base + 100, data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } } },
+    { type: 'assistant/chunk', seq: seq + 2, time: base + 300, data: { turn, step, chunk: { type: 'text-delta', index: 1, text: 'hi' } } },
+    { type: 'tool/call', seq: seq + 3, time: base + 450, data: { turn, step, callId: 'call-1', name: 'bash', arguments: '{}' } },
+    { type: 'step/end', seq: seq + 4, time: base + 700, data: { turn, step } },
+  ] as SessionEvent[]
+}
+
+describe('StepTimingTracker', () => {
+  it('accumulates each phase from the step lifecycle', () => {
+    const tracker = new StepTimingTracker()
+    const events = stepEvents(1, 1, 1_000, 0)
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000)).toEqual({
+      ttft: 100, // step/start -> first chunk
+      thinking: 200, // reasoning block-start -> text delta
+      responding: 150, // text delta -> tool call
+      tools: 250, // tool call -> step/end
+    })
+  })
+
+  it('returns empty totals for a step that never started', () => {
+    const tracker = new StepTimingTracker()
+    expect(tracker.totalsAt(stepEvents(1, 1, 1_000, 0), { turn: 9, step: 9 }, 2_000)).toEqual({
+      ttft: 0, thinking: 0, responding: 0, tools: 0,
+    })
+  })
+
+  it('accumulates the open bucket to the query clock without mutating tracked state', () => {
+    const tracker = new StepTimingTracker()
+    const events = [
+      { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
+    ] as SessionEvent[]
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_250).ttft).toBe(250)
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 1_400).ttft).toBe(400)
+  })
+
+  it('matches a fresh replay when queried incrementally across appends', () => {
+    const incremental = new StepTimingTracker()
+    const first = stepEvents(1, 1, 1_000, 0)
+    incremental.totalsAt(first, { turn: 1, step: 1 }, 5_000)
+    const events = [...first, ...stepEvents(1, 2, 3_000, first.length)]
+    const fresh = new StepTimingTracker()
+    for (const position of [{ turn: 1, step: 1 }, { turn: 1, step: 2 }]) {
+      expect(incremental.totalsAt(events, position, 5_000)).toEqual(fresh.totalsAt(events, position, 5_000))
+    }
+  })
+
+  it('serves interleaved steps from one shared scan', () => {
+    const tracker = new StepTimingTracker()
+    const events = [
+      { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
+      { type: 'step/start', seq: 1, time: 1_100, data: { turn: 1, step: 2 } },
+      { type: 'assistant/chunk', seq: 2, time: 1_200, data: { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'x' } } },
+      { type: 'step/end', seq: 3, time: 1_500, data: { turn: 1, step: 2 } },
+      { type: 'step/end', seq: 4, time: 1_600, data: { turn: 1, step: 1 } },
+    ] as SessionEvent[]
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 9_000)).toEqual({ ttft: 600, thinking: 0, responding: 0, tools: 0 })
+    expect(tracker.totalsAt(events, { turn: 1, step: 2 }, 9_000)).toEqual({ ttft: 100, thinking: 0, responding: 300, tools: 0 })
+  })
+
+  it('keeps the first step/start when a duplicate arrives while the step is open', () => {
+    const tracker = new StepTimingTracker()
+    const events = [
+      { type: 'step/start', seq: 0, time: 1_000, data: { turn: 1, step: 1 } },
+      { type: 'step/start', seq: 1, time: 1_500, data: { turn: 1, step: 1 } },
+    ] as SessionEvent[]
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 2_000).ttft).toBe(1_000)
+  })
+
+  it('ignores same-coordinate events after the step closed', () => {
+    const tracker = new StepTimingTracker()
+    const events = [
+      ...stepEvents(1, 1, 1_000, 0),
+      // A stray duplicate start and a late chunk reuse the coordinates; the
+      // closed step's totals stay pinned.
+      { type: 'step/start', seq: 5, time: 9_000, data: { turn: 1, step: 1 } },
+      { type: 'assistant/chunk', seq: 6, time: 9_100, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'late' } } },
+    ] as SessionEvent[]
+    expect(tracker.totalsAt(events, { turn: 1, step: 1 }, 10_000)).toEqual({
+      ttft: 100, thinking: 200, responding: 150, tools: 250,
+    })
+  })
+})

+ 74 - 0
packages/ui/tui/tests/transcript-card-cache.spec.ts

@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
+import { ContextCardComponent, ToolCardComponent } from '../src/components/transcript.ts'
+import { parseArguments } from '../src/components/content.ts'
+import { createPalette, markdownTheme } from '../src/components/theme.ts'
+
+const palette = createPalette(false)
+const mdTheme = markdownTheme(palette)
+
+function toolCard(): ToolCardComponent {
+  return new ToolCardComponent('bash', parseArguments('{"command":"ls"}'), undefined, 10, 2_000, palette, mdTheme)
+}
+
+function toolResult(text: string): Extract<SessionEvent, { type: 'tool/result' }>['data'] {
+  const message = createToolResultMessage({
+    callId: CallId('call-1'),
+    content: [{ type: 'text', text }],
+    isError: false,
+  })
+  return { turn: 1, step: 1, message }
+}
+
+// pi-tui re-renders every component each frame; the cards must serve repeat
+// same-width renders from their line cache and drop it on every state change.
+describe('transcript card render caches', () => {
+  it('tool card: repeat same-width renders return the cached rows', () => {
+    const card = toolCard()
+    const first = card.render(80)
+    expect(card.render(80)).toBe(first)
+    const narrower = card.render(60)
+    expect(narrower).not.toBe(first)
+    expect(card.render(60)).toBe(narrower)
+  })
+
+  it('tool card: result, visibility, and invalidate() each drop the cache', () => {
+    const card = toolCard()
+    const pending = card.render(80)
+    card.updateResult(toolResult('output line'))
+    const settled = card.render(80)
+    expect(settled).not.toBe(pending)
+    expect(settled.join('\n')).toContain('●')
+
+    card.setVisibility('hidden')
+    expect(card.render(80)).toEqual([])
+
+    card.setVisibility('collapsed')
+    const restored = card.render(80)
+    expect(restored).toEqual(settled)
+    expect(restored).not.toBe(settled)
+
+    card.invalidate()
+    expect(card.render(80)).not.toBe(restored)
+  })
+
+  it('context card: caches by width and drops on setExpanded and invalidate()', () => {
+    const card = new ContextCardComponent('workspace-context', 'line one\nline two', 10, palette)
+    const first = card.render(80)
+    expect(card.render(80)).toBe(first)
+
+    // Same width across the mutation, so a hit here would prove a kept cache.
+    card.setExpanded(true)
+    const expanded = card.render(80)
+    expect(expanded).not.toBe(first)
+    expect(card.render(80)).toBe(expanded)
+
+    card.invalidate()
+    const reRendered = card.render(80)
+    expect(reRendered).not.toBe(expanded)
+    expect(reRendered).toEqual(expanded)
+
+    expect(card.render(60)).not.toBe(reRendered)
+  })
+})