diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.i18n.yaml
new file mode 100644
index 0000000000..d7cdbb3c3c
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.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
+2026-07-21-tui-footer-cache-hit-rate.md: aaee8ed31ff8f20370f490d3ce27c8705cda3e16
+2026-07-21-tui-footer-cache-hit-rate.zh.md: 67a7aa474d98878a5bc0bc0a76a8c2ccad004e9b
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.md b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.md
new file mode 100644
index 0000000000..aaee8ed31f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.md
@@ -0,0 +1,41 @@
+# Agent Note: TUI footer shows the session cache hit rate
+
+Status: implemented
+
+English | [中文](2026-07-21-tui-footer-cache-hit-rate.zh.md)
+
+## Problem
+
+The footer summed the session's token usage as `↑ ↓`, where `↑` is the uncached input reported by the model. `TokenUsage` counts are disjoint: billed prompt tokens are `inputTokens` (uncached) plus `cacheReadTokens` and `cacheWriteTokens`. With only the uncached number visible, a user could not tell how much of each turn's prompt the provider cache served — the signal that most directly reflects whether the reused request prefix is paying off. On a long session dominated by cache reads the `↑` figure stays small and hides that the prompt is large but cheap.
+
+## Decision
+
+The footer appends `cache %` after `↑ ↓`, where the rate is the share of billed prompt tokens served from the provider cache.
+
+- `TokenTotals` accumulates the four disjoint buckets (`input`, `output`, `cacheRead`, `cacheWrite`). `addUsage` folds one call's `TokenUsage` into the totals, treating a missing `cacheReadTokens`/`cacheWriteTokens` as zero.
+- `cacheHitRate(totals)` is `round(cacheRead / (input + cacheRead + cacheWrite) * 100)`, and `undefined` before any input is billed. `FooterComponent` omits the whole ` cache N%` segment while the rate is `undefined`, so an empty session shows no meaningless zero.
+- `↑` keeps meaning uncached input, not billed input: the disjoint-bucket convention holds across the footer, and the cache percent supplies the reuse signal the raw counts cannot.
+- Totals are rebuilt on mount by `sessionTokens`, which sums usage over `assistant/message` events (never `assistant/chunk`, to avoid double counting), and updated live from each `assistant/message` event that carries usage.
+
+## Alternatives considered
+
+**Show billed input (`input + cacheRead + cacheWrite`) as `↑` instead of a separate percent.** Rejected: it would redefine `↑` away from the disjoint `inputTokens` bucket the rest of the harness reports, and it would still hide the reuse share the user actually wants; a derived percent adds the signal without overloading the count.
+
+**Compute the rate against all tokens (`input + output + cache`).** Rejected: output tokens are never cache-served, so folding them into the denominator understates the rate for no meaning; cache hit rate is a property of the prompt.
+
+**Drop `cacheWrite` from the denominator.** Rejected: cache writes are billed input the provider spent to populate the cache, so excluding them overstates the hit rate on a writing turn. DeepSeek reports no cache-write metric today, but the formula stays general and the write path is covered.
+
+**Render `cache 0%` on an empty session.** Rejected: the billed input is `0`, the ratio is `0/0`, and a `0%` badge on a fresh session is a lie about a value that does not exist yet; the segment stays hidden until input is billed.
+
+**Give the metric its own right-aligned footer element beside `tools:`.** Rejected: it derives from the adjacent token counts and reads best in the `input → output → cache` order; grouping it left also keeps the lower-priority `tools:` indicator as the element that clips first under width pressure, matching the footer's existing layout priority.
+
+## Consequences
+
+- The left group grew by ` cache N%`, so on a narrow footer the right-side `tools:` state clips sooner. This follows the footer's pre-existing left-priority truncation and is an accepted trade-off.
+- The metric is best-effort live UI state derived from `assistant/message` usage: rebuilt from the session on mount, updated live, and never persisted.
+- `packages/ui/tui/src/index.ts` stays at 100 % per-file coverage.
+- The `examples/tui-agent` terminal snapshots carry the segment: a turn with cache reads renders e.g. `cache 49%`, and a first cold turn renders `cache 0%`.
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` drives the footer through the real `createTuiChat`: an empty session renders `↑0 ↓0` with no cache segment (the hidden path), a cold turn (`inputTokens` only) renders `cache 0%`, and a live warm turn carrying `cacheReadTokens` and `cacheWriteTokens` updates it to `cache 60%` while no longer showing `cache 0%`. The `examples/tui-agent` snapshot suite replays green against the recorded expected output.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.zh.md
new file mode 100644
index 0000000000..67a7aa474d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-footer-cache-hit-rate.zh.md
@@ -0,0 +1,41 @@
+# Agent Note: TUI 页脚展示会话缓存命中率
+
+Status: implemented
+
+[English](2026-07-21-tui-footer-cache-hit-rate.md) | 中文
+
+## Problem
+
+页脚原本把会话的 token 用量汇总为 `↑ ↓`,其中 `↑` 是模型上报的未缓存输入。`TokenUsage` 的各项计数互不重叠:计费的输入 token 由 `inputTokens`(未缓存)加上 `cacheReadTokens` 与 `cacheWriteTokens` 构成。只暴露未缓存的那个数字,用户就无从判断每轮提示词有多少由提供方缓存承接——而这恰是最能反映复用的请求前缀是否奏效的信号。在以缓存读取为主的长会话里,`↑` 始终很小,掩盖了提示词其实很大但很便宜的事实。
+
+## Decision
+
+页脚在 `↑ ↓` 之后追加 `cache %`,该比率是计费输入 token 中由提供方缓存承接的占比。
+
+- `TokenTotals` 累加四个互不重叠的桶(`input`、`output`、`cacheRead`、`cacheWrite`)。`addUsage` 把单次调用的 `TokenUsage` 折入总量,缺失的 `cacheReadTokens`/`cacheWriteTokens` 视为零。
+- `cacheHitRate(totals)` 为 `round(cacheRead / (input + cacheRead + cacheWrite) * 100)`,在尚无输入计费前返回 `undefined`。比率为 `undefined` 时 `FooterComponent` 整段略去 ` cache N%`,因此空会话不会显示无意义的零。
+- `↑` 仍表示未缓存输入,而非计费输入:页脚全程遵守互不重叠的桶约定,缺失的复用信号由缓存百分比补足。
+- 挂载时由 `sessionTokens` 重建总量,它对带 usage 的 `assistant/message` 事件求和(绝不用 `assistant/chunk`,以免重复计数);此后每条携带 usage 的 `assistant/message` 事件都会实时更新。
+
+## Alternatives considered
+
+**把计费输入(`input + cacheRead + cacheWrite`)作为 `↑`,不单列百分比。** 否决:这会让 `↑` 偏离 harness 其余部分上报的互不重叠 `inputTokens` 桶,且仍旧藏住用户真正想要的复用占比;派生一个百分比既补上信号,又不给计数加载额外含义。
+
+**用全部 token(`input + output + cache`)作分母计算比率。** 否决:输出 token 从不由缓存承接,把它折进分母只会无意义地拉低比率;缓存命中率是提示词的属性。
+
+**从分母里去掉 `cacheWrite`。** 否决:缓存写入是提供方为填充缓存而付费的计费输入,剔除它会在写入的那一轮高估命中率。DeepSeek 目前不上报缓存写入指标,但公式保持通用,写入路径也有覆盖。
+
+**在空会话上渲染 `cache 0%`。** 否决:此时计费输入为 `0`,比值是 `0/0`,在全新会话上打出 `0%` 是对一个尚不存在的值撒谎;在输入计费之前该段一直隐藏。
+
+**给该指标单独一个右对齐的页脚元素,紧挨 `tools:`。** 否决:它派生自相邻的 token 计数,按 `input → output → cache` 的顺序阅读最顺;左置分组还让优先级更低的 `tools:` 指示成为宽度紧张时最先被裁剪的元素,与页脚既有的布局优先级一致。
+
+## Consequences
+
+- 左段增加了 ` cache N%`,因此窄终端上右侧的 `tools:` 状态更早被裁剪。这沿用页脚既有的左段优先裁剪策略,是可接受的取舍。
+- 该指标是从 `assistant/message` 的 usage 派生的尽力而为实时 UI 状态:挂载时从会话重建、随后实时更新、从不持久化。
+- `packages/ui/tui/src/index.ts` 保持 100% 单文件覆盖率。
+- `examples/tui-agent` 终端快照带有该段:有缓存读取的一轮渲染为如 `cache 49%`,首个冷启动轮次渲染为 `cache 0%`。
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` 通过真实的 `createTuiChat` 驱动页脚:空会话渲染 `↑0 ↓0` 且无缓存段(隐藏路径),冷启动一轮(仅 `inputTokens`)渲染 `cache 0%`,随后实时的热轮次携带 `cacheReadTokens` 与 `cacheWriteTokens`,把它更新为 `cache 60%` 且不再显示 `cache 0%`。`examples/tui-agent` 快照套件对已录制的预期输出回放通过。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml
index 03751f7029..ddf4792769 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.i18n.yaml
@@ -2,5 +2,5 @@
# 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
-2026-07-21-tui-steering-queue-badge.md: 3f04f66df8886fe8119106ed09647cec4618bbf9
-2026-07-21-tui-steering-queue-badge.zh.md: f55b78ffdf1551792aa05b5ce1f12182870036df
+2026-07-21-tui-steering-queue-badge.md: b29a4667e778e65b0678f946fcaa34b79c4d7da0
+2026-07-21-tui-steering-queue-badge.zh.md: 4bfce461e11bce1773d6e0b15aabecf6a6a6144c
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md
index 3f04f66df8..b29a4667e7 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.md
@@ -6,13 +6,13 @@ English | [中文](2026-07-21-tui-steering-queue-badge.zh.md)
## Problem
-While a turn runs, an editor submission calls `agent.steer()` and joins the steering queue behind the running turn ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). The running status line read only `Working — Enter sends steering, Esc cancels`, so pressing Enter gave no feedback that the message landed or how many were waiting to reach the model. A user steering several times could not tell the queue from a dropped keystroke.
+While a turn runs, an editor submission calls `agent.steer()` and joins the steering queue behind the running turn ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). The running status line ended only with the `Enter sends steering, Esc cancels` hint, so pressing Enter gave no feedback that the message landed or how many were waiting to reach the model. A user steering several times could not tell the queue from a dropped keystroke.
## Decision
The agent's inbox is the authoritative steering queue but is not observable from the TUI, so the badge is a live count reconstructed from the public `agent/queued` and `steering/message` events rather than a projection of the queue itself.
-- The running status line composes through `formatRunningStatus(queued)`: `Working — ${queued} queued · Enter sends steering, Esc cancels` when `queued > 0`, and the plain hint at zero.
+- The running status line composes through `formatTurnStatus`, which inserts a `${queued} queued · ` badge before the `Enter sends steering, Esc cancels` hint when `queued > 0` and shows the plain hint at zero; the phase label and elapsed timing before it are the [verbose status line](2026-07-21-tui-verbose-status-line.md)'s.
- `createTuiChat` owns a `pendingSteering` counter: `+1` on each `agent/queued` for this agent whose `info.steering` is set, `-1` (floored at zero) on each `steering/message` session event as the loop drains one, and reset to zero whenever the agent leaves `running`.
- The count refreshes onto the live `Loader` through `setMessage`; the refresh is a no-op while idle because the loader exists only during a running turn.
- The reset lives in the `agent/status` transition, not in `setStatus`, because `setStatus` also runs on mid-turn palette changes and must not clear a live count.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md
index f55b78ffdf..4bfce461e1 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-steering-queue-badge.zh.md
@@ -6,13 +6,13 @@ Status: implemented
## Problem
-轮次运行期间,编辑器提交会调用 `agent.steer()`,在运行中的轮次后面加入 steering(中途引导)队列([前门 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。运行时的状态行只显示 `Working — Enter sends steering, Esc cancels`,因此按下 Enter 后没有任何反馈表明消息已入队、也看不出有多少条正在等待送达模型。连续 steering 多次的用户无法把队列和被吞掉的按键区分开。
+轮次运行期间,编辑器提交会调用 `agent.steer()`,在运行中的轮次后面加入 steering(中途引导)队列([前门 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。运行时的状态行只以 `Enter sends steering, Esc cancels` 提示收尾,因此按下 Enter 后没有任何反馈表明消息已入队、也看不出有多少条正在等待送达模型。连续 steering 多次的用户无法把队列和被吞掉的按键区分开。
## Decision
agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 TUI 无法观测它,因此徽标是从公开的 `agent/queued` 与 `steering/message` 事件重建出的实时计数,而非对队列本身的投影。
-- 运行时的状态行经 `formatRunningStatus(queued)` 组装:`queued > 0` 时为 `Working — ${queued} queued · Enter sends steering, Esc cancels`,为零时是纯提示文本。
+- 运行时的状态行经 `formatTurnStatus` 组装:`queued > 0` 时在 `Enter sends steering, Esc cancels` 提示前插入 `${queued} queued · ` 徽标,为零时是纯提示文本;其前的阶段标签与耗时归[详细状态行](2026-07-21-tui-verbose-status-line.md)所有。
- `createTuiChat` 持有一个 `pendingSteering` 计数器:每收到一个针对本 agent 且 `info.steering` 为真的 `agent/queued` 就 `+1`,agent loop(智能体循环)每排空一条时随对应的 `steering/message` 会话事件 `-1`(下限为零),agent 一旦离开 `running` 状态即重置为零。
- 计数通过 `setMessage` 刷新到实时的 `Loader` 上;空闲时刷新是空操作,因为 loader 只在运行中的轮次期间存在。
- 重置放在 `agent/status` 状态切换里,而非 `setStatus` 中,因为 `setStatus` 在轮次中途的颜色方案变化时也会运行,绝不能清掉一个实时计数。
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml
new file mode 100644
index 0000000000..319f28ea61
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.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
+2026-07-21-tui-verbose-status-line.md: f277afd3a874b30a29dc0ef193740f636d22290b
+2026-07-21-tui-verbose-status-line.zh.md: 9fa7cf29c67245382bbee6b72f2710c5550d7f54
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md
new file mode 100644
index 0000000000..f277afd3a8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md
@@ -0,0 +1,36 @@
+# Agent Note: The running status line shows the turn phase and elapsed time
+
+Status: implemented
+
+English | [中文](2026-07-21-tui-verbose-status-line.zh.md)
+
+## Problem
+
+While a turn ran, the [full-screen TUI](2026-07-17-dedicated-full-screen-tui-front-door.md) showed a single static "Working" spinner. It conveyed neither how long the current step had taken nor what the agent was doing — waiting on the model, thinking, streaming a response, or running tools — so a slow or stalled turn was indistinguishable from a fast one.
+
+## Decision
+
+- While a turn runs, the status line above the editor shows a derived phase label with elapsed time, keeping the trailing `— Enter sends steering, Esc cancels` hint. The four phases and their labels are `waiting` → "Waiting for the first token", `thinking` → "Thinking", `responding` → "Responding", and `executing` → "Executing tools".
+- The phase is presentation state the TUI derives from live session events, not a session event or agent status of its own. `step/start` enters `waiting`; an `assistant/chunk` reasoning delta or reasoning block-start enters `thinking`; a text delta or text block-start enters `responding`; a `tool/call` enters `executing`. The event map is merge-extensible, so every other event kind falls through a default and leaves the phase unchanged.
+- The label reports two clocks — ` · total ` — except `waiting`, which shows only the step total. The phase clock resets on a genuine phase change or a new step; the step clock resets on `step/start`. Durations format as `8s` below a minute and `1m05s` at or above one. Tool time between `step/end` and the next `step/start` accrues to the finishing step's total.
+- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [banner sweep](2026-07-21-tui-banner-sweep.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`.
+
+## Alternatives considered
+
+**Emit the phase as a session event or agent status.** Rejected: the phase is a presentation detail the TUI reconstructs from events already logged. A durable, model-visible phase would demand a new session event under the model-visible ⟺ logged rule, for no model benefit.
+
+**Reuse pi-tui's `Loader` animation timer to refresh the elapsed text.** Not available: the vendored `Loader` animates only its spinner glyph, and its dist is not ours to change. The TUI owns a separate one-second interval, cleared on teardown.
+
+**Infer the phase from tool-drain or streaming-component state.** Rejected: the `step/start`, `assistant/chunk`, and `tool/call` lifecycle events are cleaner signals, already handled in the same live listener, and avoid coupling the status line to other components.
+
+**Show only elapsed time, or only the phase.** Rejected: both are wanted — the per-phase time answers what the agent is doing, the per-step total answers how long the step has taken.
+
+## Consequences
+
+- The status line reads, for example, `Thinking 4s · total 8s — Enter sends steering, Esc cancels`, so the agent's current activity and step duration are legible and a stall is visible.
+- Phase detection is best-effort presentation: an unhandled future chunk or event kind leaves the last phase in place and never throws.
+- Exactly one `setInterval` runs per active turn, cleared with the controller on every idle or disposed transition and on shutdown.
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` pins each phase label against its triggering event (`step/start`, reasoning and text deltas and block-starts, `tool/call`), that a new step reopens the wait window, that the elapsed time advances on the controller's own timer past one second, that a step beyond a minute renders `1m…`, that a mid-turn color-scheme change preserves the phase and elapsed time, and that a live event arriving before the turn runs moves no status. Verified live in tmux.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md
new file mode 100644
index 0000000000..9fa7cf29c6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md
@@ -0,0 +1,36 @@
+# Agent Note: 运行状态行展示轮次阶段与已用时长
+
+Status: implemented
+
+[English](2026-07-21-tui-verbose-status-line.md) | 中文
+
+## 问题
+
+在轮次运行期间,[全屏 TUI](2026-07-17-dedicated-full-screen-tui-front-door.md) 只显示一个静态的 "Working" loader 动画。它既不表明当前步骤已耗时多久,也不表明 agent(智能体)正在做什么——等待模型、思考、流式输出回复,还是运行工具——因此运行缓慢或卡住的轮次与运行很快的轮次无从区分。
+
+## 决策
+
+- 轮次运行期间,编辑器上方的状态行显示一个派生的阶段标签及已用时长,并保留末尾的 `— Enter sends steering, Esc cancels` 提示。四个阶段及其标签为 `waiting` → "Waiting for the first token"、`thinking` → "Thinking"、`responding` → "Responding"、`executing` → "Executing tools"。
+- 阶段是 TUI 从实时会话事件派生出的呈现状态,而非它自有的会话事件或 agent 状态。`step/start` 进入 `waiting`;`assistant/chunk` 的 reasoning 分片或 reasoning 块开始(`block-start`)进入 `thinking`;text 分片或 text 块开始进入 `responding`;`tool/call` 进入 `executing`。该事件映射可合并扩展,因此其余任何事件类型都落入默认分支,保持阶段不变。
+- 标签汇报两个时钟——` · total `——但 `waiting` 只显示步骤总时长。阶段时钟在真正发生阶段切换或进入新步骤时重置;步骤时钟在 `step/start` 时重置。时长在不足一分钟时格式化为 `8s`,达到或超过一分钟时格式化为 `1m05s`。`step/end` 与下一个 `step/start` 之间的工具时间计入结束步骤的总时长。
+- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与 [banner 扫入动画](2026-07-21-tui-banner-sweep.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。
+
+## 曾考虑的替代方案
+
+**将阶段作为会话事件或 agent 状态发出。** 已否决:阶段是 TUI 从已记录事件重建出的呈现细节。一个持久、模型可见的阶段会依据 model-visible ⟺ logged 规则要求新增一个会话事件,而对模型没有任何好处。
+
+**复用 pi-tui 的 `Loader` 动画定时器来刷新已用时长文本。** 不可行:`Loader` 是 vendored 依赖,只驱动其加载动画字形,其 dist 不归我们改动。TUI 自持一个独立的每秒 interval,并在拆卸时清除。
+
+**从工具耗尽或流式组件状态推断阶段。** 已否决:`step/start`、`assistant/chunk` 和 `tool/call` 这些生命周期事件是更干净的信号,已在同一个实时监听器中处理,且避免让状态行与其他组件耦合。
+
+**只显示已用时长,或只显示阶段。** 已否决:两者都需要——按阶段的时长回答 agent 在做什么,按步骤的总时长回答该步骤已耗时多久。
+
+## 后果
+
+- 状态行例如显示 `Thinking 4s · total 8s — Enter sends steering, Esc cancels`,从而 agent 的当前活动与步骤时长一目了然,卡顿也随之可见。
+- 阶段检测是尽力而为的呈现:未处理的未来分片或事件类型会保持上一个阶段不变,绝不抛错。
+- 每个活动轮次恰好运行一个 `setInterval`,在每次向 idle 或 disposed 的转变以及关停时随控制器一并清除。
+
+## 测试
+
+`packages/ui/tui/tests/tui.spec.ts` 针对触发事件锁定每个阶段标签(`step/start`、reasoning 与 text 的分片及块开始、`tool/call`),并锁定新步骤会重新开启等待窗口、已用时长在控制器自有定时器上超过一秒后递增、超过一分钟的步骤渲染为 `1m…`、轮次进行中的颜色方案变化会保留阶段与已用时长,以及轮次开始前到达的实时事件不移动任何状态。已在 tmux 中实机验证。
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index 00a8f25ab7..af2676394d 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -818,6 +818,37 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
+ it('shows the session cache hit rate in the footer and updates it live', async () => {
+ // Empty session: no input billed yet, so the cache segment is hidden.
+ // A cwd without "cache" in it keeps the negative assertion unambiguous.
+ const empty = await setup({ cwd: '/opt' })
+ expect(empty.terminal.output).toContain('↑0 ↓0')
+ expect(empty.terminal.output).not.toContain('cache')
+ await dispose(empty)
+
+ const result = await setup({
+ beforeMount(session) {
+ // Cold call: 10 billed input tokens, none served from cache.
+ appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
+ },
+ })
+ expect(result.terminal.output).toContain('cache 0%')
+
+ result.terminal.output = ''
+ // Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed
+ // input, so 30 of the 50 total prompt tokens are hits → 60%.
+ appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
+ inputTokens: 5,
+ outputTokens: 5,
+ cacheReadTokens: 30,
+ cacheWriteTokens: 5,
+ })
+ await tick()
+ expect(result.terminal.output).toContain('cache 60%')
+ expect(result.terminal.output).not.toContain('cache 0%')
+ await dispose(result)
+ })
+
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
const result = await setup()