Skip to content

Commit f4aae2a

Browse files
authored
feat(cli): refresh the statusline ctx segment per settled provider request (#4545) (#4550)
* docs(cli): design for live TUI ctx updates (#4545) * feat(cli): refresh the statusline ctx segment per settled provider request (#4545) The ctx segment only moved when the end-of-turn token_usage event landed, so a long agentic turn burned context with the indicator frozen at the previous turn's value. The Host already commits a latest-context snapshot at every settled provider request (the desktop inspector's data source), so pull it on the desktop's own signal: trace-relevant events schedule a 400ms-debounced getContextDiagnostics read, and the latest issued read writes contextRemaining straight into the statusline usage. - tui-context-refresh.ts: desktop's trace-relevant event set + a restart-on-event debouncer whose revision guard lets only the latest issued query apply; a failed read leaves the last value standing. - pi-tui-runner.ts: observe() on the shared turn-drain onEvent (covers user and Host-attached turns) and on resumeLatest; cancel on teardown; stale-session guard drops pre-switch results. ctxRefreshTicker input option injects the timer for tests. Zero protocol/runtime changes: no new event type, no persistence or billing semantics touched (#972). Turn-end token_usage stays the authoritative persisted record.
1 parent 6ab7251 commit f4aae2a

7 files changed

Lines changed: 814 additions & 0 deletions

File tree

docs/tui-live-ctx-updates.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# TUI live ctx updates (#4545)
21+
22+
Status: design. Issue: https://github.com/apache/maka/issues/4545
23+
24+
## Problem
25+
26+
The TUI statusline `ctx used/window pct%` segment updates **once per turn**, when
27+
the turn fully ends. During a long agentic turn — dozens of tool steps over
28+
minutes, exactly when the context grows fastest — the indicator sits stale at
29+
the previous turn's value, so the user loses the signal that says "time to
30+
`/compact` or wrap up".
31+
32+
## Audit: how it works today
33+
34+
Every claim below was verified against `main` (`6c632b1339`).
35+
36+
### Current TUI path (push, once per turn)
37+
38+
| # | Claim | Evidence |
39+
|---|-------|----------|
40+
| 1 | The ctx segment renders `used = modelContextWindow - usage.contextRemaining`; window comes from the model catalog | `packages/cli/src/pi-transcript.ts` L1678–1693 (`renderMakaPiStatusLine`), window wired at `packages/cli/src/pi-tui-runner.ts` L618, L1266 |
41+
| 2 | `usage.contextRemaining` is only written by `accumulateUsage`, reached from stored messages (transcript rebuild) or a live `token_usage` SessionEvent | `packages/cli/src/pi-transcript.ts` L248–267 (`accumulateUsage`), L472, L751, L1003 |
42+
| 3 | The runtime emits `token_usage` with `contextRemaining` exactly once per send, in the *Final usage event* block after the agent loop breaks | `packages/runtime/src/ai-sdk-backend.ts` ~L2738–2800 |
43+
| 4 | Mid-turn, every `step-finish` boundary already captures `stepUsage.inputTokens` into `lastStepInputTokens` — but it only feeds the end-of-turn computation and the durable `recordUsageCheckpoint` hook, which is fire-and-forget persistence, not a live event | `packages/runtime/src/ai-sdk-backend.ts` L2181–2202; hook contract L751–753 |
44+
| 5 | `/context` is refused mid-turn, but the gate is the TUI's own `runControl` serial lock (exists to stop prompts racing session/model switches), not a protocol limit | `packages/cli/src/pi-tui-runner.ts` L3261–3265, L882–914 |
45+
46+
### Desktop prior art (pull, per settled request)
47+
48+
| # | Claim | Evidence |
49+
|---|-------|----------|
50+
| 6 | The Host commits a latest-context snapshot at **every provider request settlement** (each LLM step), carrying `inputTokens` and `contextWindow` | `packages/runtime/src/provider-request-telemetry.ts` `finalize``emitModelCallAttempt``accounting.record({ attempt, latestContext })` (~L469–640); `packages/runtime/src/latest-context-snapshot.ts` |
51+
| 7 | The commit is awaited **before** the `finish` part is enqueued to the consumer, so any UI event that follows the step (e.g. `tool_start`) observes the snapshot already durable — no read race | `packages/runtime/src/provider-request-telemetry.ts` stream `pull` handler ~L368–390 |
52+
| 8 | `context.diagnostics.query` is a plain read: header snapshot + run-store projection read; no execution authority, no busy gate | `packages/runtime-host/src/server/context-coordinator.ts` `#queryDiagnostics`; spec `mode: 'query'` in `packages/runtime-host/src/protocol/context.ts` L107–117 |
53+
| 9 | The desktop inspector subscribes to the live session event stream and re-reads the diagnostics on trace-relevant events (`tool_start`, `tool_result`, `token_usage`, `provider_retry`, `error`, `complete`, `abort`), coalesced at 400 ms; a failed re-read leaves the last value standing | `apps/desktop/src/renderer/session-trace-refresh.ts` L21–37; `apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts` L59 (`TRACE_REFRESH_DEBOUNCE_MS = 400`), L255–274 |
54+
| 10 | Desktop derives the bar as `used = inputTokens`, `ratio = used / contextWindow`, from the snapshot alone | `session-inspector-overview-model.ts` `contextBudget()` ~L210–241 |
55+
| 11 | The TUI driver already exposes the same query; the TUI always talks to the Host | `packages/cli/src/runtime-host-session-driver.ts` L1117 (`getContextDiagnostics`); interface `packages/cli/src/session-driver.ts` L230 (optional) |
56+
| 12 | The TUI runner's `onEvent` sees every live event mid-turn | `packages/cli/src/pi-tui-runner.ts` L1455–1483 |
57+
58+
Semantics line up: the statusline's `contextRemaining = window − lastStepInputTokens`
59+
(#1067) and the snapshot's `inputTokens` describe the same settled request, so
60+
`contextRemaining ≡ diagnostics.contextWindow − diagnostics.inputTokens`.
61+
62+
## Design: reuse the desktop pull model in the TUI
63+
64+
Add a live-refresh hook to the TUI runner. No protocol, runtime, or persistence
65+
changes.
66+
67+
### New module: `packages/cli/src/tui-context-refresh.ts`
68+
69+
- `isCtxRefreshRelevantEvent(event: SessionEvent): boolean` — same event set as
70+
desktop's `TRACE_RELEVANT_EVENT_TYPES` (audit #9). `tool_start`/`tool_result`
71+
are the mid-turn step boundaries; the rest close or annotate the turn.
72+
Keeping the set identical to desktop's keeps one answer to "when is the
73+
context worth re-reading".
74+
- `createCtxRefresher({ query, apply, delayMs, schedule, cancel })` — a
75+
restart-on-call debounce with a monotonic revision counter, mirroring
76+
desktop's `createRefreshCoalescer` plus the `contextRevisionRef` guard:
77+
only the latest issued query may apply; a late or failed resolution leaves
78+
the current value standing (audit #9). Clock and timer injected, following
79+
the runner's existing `shellRunTicker` seam, so tests drive it
80+
deterministically.
81+
82+
### Wiring in `pi-tui-runner.ts`
83+
84+
In `onEvent` (audit #12), after `applyMakaSessionEventToTranscript`:
85+
86+
1. `if (isCtxRefreshRelevantEvent(event)) ctxRefresher.request()`.
87+
2. The refresher calls `input.driver.getContextDiagnostics?.()` directly —
88+
deliberately **not** through `runControl`, whose serial lock exists for
89+
mutations (audit #5).
90+
3. On `status: 'available'` with both `inputTokens` and `contextWindow`
91+
present, set `state.usage.contextRemaining = contextWindow − inputTokens`
92+
and `requestRender()`. The statusline keeps its existing formula, color
93+
thresholds, and degradation states untouched; the catalog window stays the
94+
displayed denominator, matching what the `token_usage` path already does
95+
(both windows derive from the selected model's metadata).
96+
4. Stale-session guard: the query captures `driver.getSessionId()` at request
97+
time and `apply` drops the result when it changed — session switches reset
98+
`state.usage` (`replaceTranscript`), and a pre-switch value must not land
99+
afterwards. This guard covers every switch path uniformly, so no per-switch
100+
cancellation wiring is needed; a refresh scheduled across a switch simply
101+
queries the adopted session, which is the value the statusline should show.
102+
5. Lifecycle: `ctxRefresher.cancel()` on teardown (alongside the existing
103+
ticker disposal), which also retires any in-flight query.
104+
6. Event coverage: every live turn drains through `runMakaPiTuiTurn`'s
105+
`onEvent` (user-submitted and Host-attached turns alike) and the
106+
`resumeLatest` loop — both hooked. `/compact` is deliberately not hooked:
107+
its own `token_usage` already writes the authoritative post-compact value.
108+
109+
The end-of-turn `token_usage` event stays the authoritative **persisted**
110+
record; the pull only enriches the live turn. Both derive from the same
111+
settled request, so they cannot disagree.
112+
113+
### Out of scope (recorded, not forgotten)
114+
115+
- Unlocking `/context` mid-turn over the same query path — a free follow-up,
116+
kept out of this PR to stay small.
117+
- Desktop needs nothing; it already has this granularity.
118+
- Token-level updates during one streaming request: providers only report
119+
input tokens at completion, so exact mid-request values do not exist; the
120+
pre-dispatch `bytes/4` estimate is too rough (base64 attachments) to show.
121+
122+
### Why not a new push event
123+
124+
- Protocol surface: a new SessionEvent type touches the core schema, the
125+
backend emission point, the host mapper, and rebuild/persistence semantics.
126+
- It creates a second derivation of the same number; pull keeps TUI and
127+
desktop on one source of truth (the snapshot row), so resume / backfill /
128+
compact edge cases cannot drift between two paths.
129+
- Reusing `token_usage` with partial fields was rejected: `accumulateUsage`
130+
treats it as cumulative billing input, and "incomplete usage is no usage"
131+
(#972).
132+
133+
## Test plan (`packages/cli/src/__tests__/`)
134+
135+
- Mid-turn `tool_start` with a diagnostics result → statusline ctx reflects
136+
the new value before turn end.
137+
- Debounce: a burst of relevant events within the window issues one query.
138+
- Revision guard: two overlapping queries resolve out of order → the older
139+
resolution is dropped.
140+
- Query failure / `status: 'unavailable'` → previous value stands.
141+
- Session switch between request and resolution → value not applied.
142+
- Driver without `getContextDiagnostics` (optional method) → no-op, no crash.
143+
- Turn-end `token_usage` still lands exactly as today (regression guard on
144+
`accumulateUsage`).

docs/tui-live-ctx-updates.zh-CN.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# TUI ctx 实时更新:人话讲解(#4545
21+
22+
英文正式设计见 [tui-live-ctx-updates.md](./tui-live-ctx-updates.md)。这篇用大白话讲清楚:
23+
问题是什么、desktop 已经怎么做的、我们要抄什么、以及那些名词都是什么意思。
24+
25+
## 一句话版本
26+
27+
TUI 底部状态栏有个 `ctx 45k/200k 23%` 的指示器,告诉你"上下文装满多少了"。
28+
现在它**每轮对话结束才刷新一次**;而 desktop 版 maka 是**模型每跑完一步就刷新**
29+
方案:把 desktop 的刷新方式原样搬到 TUI,只改 CLI 包,不动任何协议。
30+
31+
## 先搞懂名词
32+
33+
| 名词 | 人话解释 |
34+
|------|----------|
35+
| **token** | 模型读/写文字的最小计费单位,约等于一个词的碎片。你给它的和它回你的都按 token 算。 |
36+
| **上下文窗口(context window)** | 模型一次能看到的最大 token 总量,比如 200k。系统提示、历史消息、工具定义、工具结果全塞在里面。装满了就必须压缩(compact),否则报错或降智。 |
37+
| **ctx 指示器** | TUI 状态栏上的 `ctx 已用/总量 百分比`,告诉你窗口还剩多少。 |
38+
| **turn(轮)** | 你按一次回车 → agent 完全停下来,这整个过程。agentic 场景下,一个 turn 里模型可能反复"想一步、调个工具、再想一步",跑几分钟。 |
39+
| **step(步)** | 一个 turn 内部的每一次"模型调用 + 工具执行"循环。一个 turn = 很多 step。ctx 就是在 step 之间涨上去的(工具结果塞进了上下文)。 |
40+
| **请求结算(settle)** | 一次模型请求跑完,provider(模型厂商)上报"这次实际用了多少 token"。**只有结算时才能拿到精确数字**——流式输出途中谁也不知道这次请求的输入到底多少 token,所以"token 级实时"在原理上就不可能,谁也做不到。 |
41+
| **token_usage 事件** | runtime 在**整个 turn 结束后**发的一条消息,里面有这次 turn 的用量账单。TUI 现在的 ctx 就靠它刷新——这就是"每轮才更新"的根源。 |
42+
| **快照(latest-context snapshot)** | Host(后端进程)在**每次请求结算时**写的一行记录:"最近一次请求,输入 X token,窗口 Y。" 每个 step 都会更新,不用等 turn 结束。desktop 的 ctx 条就是读这行记录。 |
43+
| **pull(拉)vs push(推)** | push = 后端主动把数据塞给界面(token_usage 事件就是 push,但一轮只推一次)。pull = 界面自己开口问:"现在上下文多满了?" desktop 用的是 pull。 |
44+
| **防抖(debounce)** | 事件密集来时(一步里可能连发好几个事件),等 400ms 合并成一次查询,避免刷屏。desktop 就是这么做的,我们照抄。 |
45+
| **busy gate(忙锁)** | TUI 自己的一把锁:turn 运行时禁止执行 `/model``/session` 这类会改状态的命令,防止打架。`/context` 命令现在也被它挡住——但注意,这是 TUI 自己的规定,**不是后端禁止查询**。我们的刷新钩子绕开这把锁直接问后端,合法。 |
46+
| **竞态(race)** | "查询发出时数据还没写好"的风险。已排除:后端是先落库快照、再发事件给界面(顺序有 await 保证),所以界面收到事件时快照必然已就绪。 |
47+
48+
## 问题到底是怎么回事
49+
50+
```
51+
你 → 发消息 ── turn 开始 ────────────────────────────── turn 结束
52+
step1 step2 step3 ... step20 │
53+
│ │ │ │ │
54+
ctx 涨了 又涨了 又涨了 又涨了 token_usage 事件
55+
│ │ │ │ │
56+
TUI 状态栏: 【旧值】【旧值】【旧值】...【旧值】 【终于更新!】
57+
desktop: 【更新】【更新】【更新】...【更新】 【更新】
58+
```
59+
60+
最讽刺的是:**数据后端早就有**——每个 step 结束都记了账(审计 #4/#6),
61+
只是没人告诉 TUI 的界面。desktop 会主动去问,TUI 不会。
62+
63+
## 方案(抄 desktop 的作业)
64+
65+
在 TUI 的事件处理入口(`onEvent`,每个 live 事件都经过这里)挂一个钩子:
66+
67+
1. 收到 `tool_start` / `tool_result` 等"上下文可能变了"的事件 → 触发防抖刷新;
68+
2. 400ms 防抖后,调 `driver.getContextDiagnostics()`(现成的接口,desktop 同款)
69+
问 Host:"最新快照是啥?";
70+
3. 拿到 `inputTokens`(已用)和 `contextWindow`(总量)→ 更新状态栏数字;
71+
4. 防护措施照抄 desktop:
72+
- **版本号防乱序**:两次查询先后发出、后发先至时,丢弃过期的结果;
73+
- **失败保留旧值**:查询失败不清空,原来的数字继续站着;
74+
- **会话切换丢弃**:查询回来时如果用户已经换了会话,结果作废。
75+
76+
turn 结束时原本的 `token_usage` 事件照常到达——它和快照说的是同一次请求,
77+
数字天然一致,不打架。
78+
79+
## 为什么不选别的路
80+
81+
- **新加 push 事件**:要动核心事件协议、runtime 发射点、host 转发、持久化语义,
82+
还会造成"同一个数两条来源"的漂移风险。pull 方案零协议改动,且 TUI 和
83+
desktop 读的是同一行记录,永远不会不一致。
84+
- **复用 token_usage 事件发半成品**:它会累加计费字段,发半个会把账算重复,
85+
违反仓库"用量不完整就当没有"(#972)的原则。
86+
- **请求发出前用字节数/4 估一个值**:误差太大(图片附件的 base64 会严重失真),
87+
不值得。
88+
89+
## 改动范围
90+
91+
只动 `packages/cli`
92+
93+
- 新增 `tui-context-refresh.ts`:事件过滤器 + 防抖器(约几十行,仿 desktop 的
94+
`session-trace-refresh.ts`);
95+
- `pi-tui-runner.ts``onEvent` 里挂钩子、写结果、渲染;
96+
- 测试:中途刷新、防抖合并、乱序丢弃、失败保留、切会话丢弃、turn 末事件回归。

0 commit comments

Comments
 (0)