From 1d07c14909fd1b270999d0d10ddb29bcff6da99a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:25:27 +0800 Subject: [PATCH 1/2] docs(cli): design for live TUI ctx updates (#4545) --- docs/tui-live-ctx-updates.md | 144 +++++++++++++++++++++++++++++ docs/tui-live-ctx-updates.zh-CN.md | 96 +++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 docs/tui-live-ctx-updates.md create mode 100644 docs/tui-live-ctx-updates.zh-CN.md diff --git a/docs/tui-live-ctx-updates.md b/docs/tui-live-ctx-updates.md new file mode 100644 index 0000000000..28653a3f62 --- /dev/null +++ b/docs/tui-live-ctx-updates.md @@ -0,0 +1,144 @@ + + +# TUI live ctx updates (#4545) + +Status: design. Issue: https://github.com/apache/maka/issues/4545 + +## Problem + +The TUI statusline `ctx used/window pct%` segment updates **once per turn**, when +the turn fully ends. During a long agentic turn — dozens of tool steps over +minutes, exactly when the context grows fastest — the indicator sits stale at +the previous turn's value, so the user loses the signal that says "time to +`/compact` or wrap up". + +## Audit: how it works today + +Every claim below was verified against `main` (`6c632b1339`). + +### Current TUI path (push, once per turn) + +| # | Claim | Evidence | +|---|-------|----------| +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | + +### Desktop prior art (pull, per settled request) + +| # | Claim | Evidence | +|---|-------|----------| +| 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` | +| 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 | +| 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 | +| 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 | +| 10 | Desktop derives the bar as `used = inputTokens`, `ratio = used / contextWindow`, from the snapshot alone | `session-inspector-overview-model.ts` `contextBudget()` ~L210–241 | +| 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) | +| 12 | The TUI runner's `onEvent` sees every live event mid-turn | `packages/cli/src/pi-tui-runner.ts` L1455–1483 | + +Semantics line up: the statusline's `contextRemaining = window − lastStepInputTokens` +(#1067) and the snapshot's `inputTokens` describe the same settled request, so +`contextRemaining ≡ diagnostics.contextWindow − diagnostics.inputTokens`. + +## Design: reuse the desktop pull model in the TUI + +Add a live-refresh hook to the TUI runner. No protocol, runtime, or persistence +changes. + +### New module: `packages/cli/src/tui-context-refresh.ts` + +- `isCtxRefreshRelevantEvent(event: SessionEvent): boolean` — same event set as + desktop's `TRACE_RELEVANT_EVENT_TYPES` (audit #9). `tool_start`/`tool_result` + are the mid-turn step boundaries; the rest close or annotate the turn. + Keeping the set identical to desktop's keeps one answer to "when is the + context worth re-reading". +- `createCtxRefresher({ query, apply, delayMs, schedule, cancel })` — a + restart-on-call debounce with a monotonic revision counter, mirroring + desktop's `createRefreshCoalescer` plus the `contextRevisionRef` guard: + only the latest issued query may apply; a late or failed resolution leaves + the current value standing (audit #9). Clock and timer injected, following + the runner's existing `shellRunTicker` seam, so tests drive it + deterministically. + +### Wiring in `pi-tui-runner.ts` + +In `onEvent` (audit #12), after `applyMakaSessionEventToTranscript`: + +1. `if (isCtxRefreshRelevantEvent(event)) ctxRefresher.request()`. +2. The refresher calls `input.driver.getContextDiagnostics?.()` directly — + deliberately **not** through `runControl`, whose serial lock exists for + mutations (audit #5). +3. On `status: 'available'` with both `inputTokens` and `contextWindow` + present, set `state.usage.contextRemaining = contextWindow − inputTokens` + and `requestRender()`. The statusline keeps its existing formula, color + thresholds, and degradation states untouched; the catalog window stays the + displayed denominator, matching what the `token_usage` path already does + (both windows derive from the selected model's metadata). +4. Stale-session guard: the query captures `driver.getSessionId()` at request + time and `apply` drops the result when it changed — session switches reset + `state.usage` (`replaceTranscript`), and a pre-switch value must not land + afterwards. This guard covers every switch path uniformly, so no per-switch + cancellation wiring is needed; a refresh scheduled across a switch simply + queries the adopted session, which is the value the statusline should show. +5. Lifecycle: `ctxRefresher.cancel()` on teardown (alongside the existing + ticker disposal), which also retires any in-flight query. +6. Event coverage: every live turn drains through `runMakaPiTuiTurn`'s + `onEvent` (user-submitted and Host-attached turns alike) and the + `resumeLatest` loop — both hooked. `/compact` is deliberately not hooked: + its own `token_usage` already writes the authoritative post-compact value. + +The end-of-turn `token_usage` event stays the authoritative **persisted** +record; the pull only enriches the live turn. Both derive from the same +settled request, so they cannot disagree. + +### Out of scope (recorded, not forgotten) + +- Unlocking `/context` mid-turn over the same query path — a free follow-up, + kept out of this PR to stay small. +- Desktop needs nothing; it already has this granularity. +- Token-level updates during one streaming request: providers only report + input tokens at completion, so exact mid-request values do not exist; the + pre-dispatch `bytes/4` estimate is too rough (base64 attachments) to show. + +### Why not a new push event + +- Protocol surface: a new SessionEvent type touches the core schema, the + backend emission point, the host mapper, and rebuild/persistence semantics. +- It creates a second derivation of the same number; pull keeps TUI and + desktop on one source of truth (the snapshot row), so resume / backfill / + compact edge cases cannot drift between two paths. +- Reusing `token_usage` with partial fields was rejected: `accumulateUsage` + treats it as cumulative billing input, and "incomplete usage is no usage" + (#972). + +## Test plan (`packages/cli/src/__tests__/`) + +- Mid-turn `tool_start` with a diagnostics result → statusline ctx reflects + the new value before turn end. +- Debounce: a burst of relevant events within the window issues one query. +- Revision guard: two overlapping queries resolve out of order → the older + resolution is dropped. +- Query failure / `status: 'unavailable'` → previous value stands. +- Session switch between request and resolution → value not applied. +- Driver without `getContextDiagnostics` (optional method) → no-op, no crash. +- Turn-end `token_usage` still lands exactly as today (regression guard on + `accumulateUsage`). diff --git a/docs/tui-live-ctx-updates.zh-CN.md b/docs/tui-live-ctx-updates.zh-CN.md new file mode 100644 index 0000000000..006197a0c8 --- /dev/null +++ b/docs/tui-live-ctx-updates.zh-CN.md @@ -0,0 +1,96 @@ + + +# TUI ctx 实时更新:人话讲解(#4545) + +英文正式设计见 [tui-live-ctx-updates.md](./tui-live-ctx-updates.md)。这篇用大白话讲清楚: +问题是什么、desktop 已经怎么做的、我们要抄什么、以及那些名词都是什么意思。 + +## 一句话版本 + +TUI 底部状态栏有个 `ctx 45k/200k 23%` 的指示器,告诉你"上下文装满多少了"。 +现在它**每轮对话结束才刷新一次**;而 desktop 版 maka 是**模型每跑完一步就刷新**。 +方案:把 desktop 的刷新方式原样搬到 TUI,只改 CLI 包,不动任何协议。 + +## 先搞懂名词 + +| 名词 | 人话解释 | +|------|----------| +| **token** | 模型读/写文字的最小计费单位,约等于一个词的碎片。你给它的和它回你的都按 token 算。 | +| **上下文窗口(context window)** | 模型一次能看到的最大 token 总量,比如 200k。系统提示、历史消息、工具定义、工具结果全塞在里面。装满了就必须压缩(compact),否则报错或降智。 | +| **ctx 指示器** | TUI 状态栏上的 `ctx 已用/总量 百分比`,告诉你窗口还剩多少。 | +| **turn(轮)** | 你按一次回车 → agent 完全停下来,这整个过程。agentic 场景下,一个 turn 里模型可能反复"想一步、调个工具、再想一步",跑几分钟。 | +| **step(步)** | 一个 turn 内部的每一次"模型调用 + 工具执行"循环。一个 turn = 很多 step。ctx 就是在 step 之间涨上去的(工具结果塞进了上下文)。 | +| **请求结算(settle)** | 一次模型请求跑完,provider(模型厂商)上报"这次实际用了多少 token"。**只有结算时才能拿到精确数字**——流式输出途中谁也不知道这次请求的输入到底多少 token,所以"token 级实时"在原理上就不可能,谁也做不到。 | +| **token_usage 事件** | runtime 在**整个 turn 结束后**发的一条消息,里面有这次 turn 的用量账单。TUI 现在的 ctx 就靠它刷新——这就是"每轮才更新"的根源。 | +| **快照(latest-context snapshot)** | Host(后端进程)在**每次请求结算时**写的一行记录:"最近一次请求,输入 X token,窗口 Y。" 每个 step 都会更新,不用等 turn 结束。desktop 的 ctx 条就是读这行记录。 | +| **pull(拉)vs push(推)** | push = 后端主动把数据塞给界面(token_usage 事件就是 push,但一轮只推一次)。pull = 界面自己开口问:"现在上下文多满了?" desktop 用的是 pull。 | +| **防抖(debounce)** | 事件密集来时(一步里可能连发好几个事件),等 400ms 合并成一次查询,避免刷屏。desktop 就是这么做的,我们照抄。 | +| **busy gate(忙锁)** | TUI 自己的一把锁:turn 运行时禁止执行 `/model`、`/session` 这类会改状态的命令,防止打架。`/context` 命令现在也被它挡住——但注意,这是 TUI 自己的规定,**不是后端禁止查询**。我们的刷新钩子绕开这把锁直接问后端,合法。 | +| **竞态(race)** | "查询发出时数据还没写好"的风险。已排除:后端是先落库快照、再发事件给界面(顺序有 await 保证),所以界面收到事件时快照必然已就绪。 | + +## 问题到底是怎么回事 + +``` +你 → 发消息 ── turn 开始 ────────────────────────────── turn 结束 + step1 step2 step3 ... step20 │ + │ │ │ │ │ + ctx 涨了 又涨了 又涨了 又涨了 token_usage 事件 + │ │ │ │ │ +TUI 状态栏: 【旧值】【旧值】【旧值】...【旧值】 【终于更新!】 +desktop: 【更新】【更新】【更新】...【更新】 【更新】 +``` + +最讽刺的是:**数据后端早就有**——每个 step 结束都记了账(审计 #4/#6), +只是没人告诉 TUI 的界面。desktop 会主动去问,TUI 不会。 + +## 方案(抄 desktop 的作业) + +在 TUI 的事件处理入口(`onEvent`,每个 live 事件都经过这里)挂一个钩子: + +1. 收到 `tool_start` / `tool_result` 等"上下文可能变了"的事件 → 触发防抖刷新; +2. 400ms 防抖后,调 `driver.getContextDiagnostics()`(现成的接口,desktop 同款) + 问 Host:"最新快照是啥?"; +3. 拿到 `inputTokens`(已用)和 `contextWindow`(总量)→ 更新状态栏数字; +4. 防护措施照抄 desktop: + - **版本号防乱序**:两次查询先后发出、后发先至时,丢弃过期的结果; + - **失败保留旧值**:查询失败不清空,原来的数字继续站着; + - **会话切换丢弃**:查询回来时如果用户已经换了会话,结果作废。 + +turn 结束时原本的 `token_usage` 事件照常到达——它和快照说的是同一次请求, +数字天然一致,不打架。 + +## 为什么不选别的路 + +- **新加 push 事件**:要动核心事件协议、runtime 发射点、host 转发、持久化语义, + 还会造成"同一个数两条来源"的漂移风险。pull 方案零协议改动,且 TUI 和 + desktop 读的是同一行记录,永远不会不一致。 +- **复用 token_usage 事件发半成品**:它会累加计费字段,发半个会把账算重复, + 违反仓库"用量不完整就当没有"(#972)的原则。 +- **请求发出前用字节数/4 估一个值**:误差太大(图片附件的 base64 会严重失真), + 不值得。 + +## 改动范围 + +只动 `packages/cli`: + +- 新增 `tui-context-refresh.ts`:事件过滤器 + 防抖器(约几十行,仿 desktop 的 + `session-trace-refresh.ts`); +- `pi-tui-runner.ts`:`onEvent` 里挂钩子、写结果、渲染; +- 测试:中途刷新、防抖合并、乱序丢弃、失败保留、切会话丢弃、turn 末事件回归。 From 4be9222680c3a0fee115eb78d5ac567219c14516 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:52:11 +0800 Subject: [PATCH 2/2] 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. --- .../cli/src/__tests__/pi-tui-runner.test.ts | 132 +++++++++ .../src/__tests__/tui-context-refresh.test.ts | 265 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 50 ++++ packages/cli/src/tui-context-refresh.ts | 126 +++++++++ scripts/check-tui-copy.mjs | 1 + 5 files changed, 574 insertions(+) create mode 100644 packages/cli/src/__tests__/tui-context-refresh.test.ts create mode 100644 packages/cli/src/tui-context-refresh.ts diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2127529a45..f9c0f83497 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -4590,6 +4590,92 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('ctx segment refreshes mid-turn from the live context snapshot (#4545)', async () => { + // 120 cols: the status line fits every segment, so ctx is not + // priority-dropped (#3421). + const terminal = new FakeTerminal(120); + const driver = new LiveCtxDriver(); + driver.diagnostics = { + status: 'available', + providerId: 'anthropic', + modelId: 'claude-sonnet-4-5', + completedAt: 10, + inputTokens: 100_000, + contextWindow: 500_000, + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + modelContextWindow: 500_000, + ctxRefreshTicker: { delayMs: 0 }, + terminal, + }); + + // A fresh session with no usage yet degrades explicitly (#3371). + await waitFor(() => plainTerminalOutput(terminal.output()).includes('ctx ?/500k')); + + terminal.input('start the work'); + terminal.input('\r'); + // The turn parks after tool_start — no token_usage has arrived, which is + // exactly the gap #4545 closes: before this, ctx stayed stale until the + // turn fully ended. + await waitFor(() => terminal.progressStates.at(-1) === true); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('npm test')); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('ctx 100k/500k 20%')); + assert.ok(driver.diagnosticsCalls >= 1); + + driver.endTurn(); + await waitFor(() => terminal.progressStates.at(-1) === false); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('ctx refresh leaves the last value standing when the snapshot read fails (#4545)', async () => { + const terminal = new FakeTerminal(120); + const driver = new LiveCtxDriver(); + driver.diagnosticsError = new Error('host not ready'); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + modelContextWindow: 500_000, + ctxRefreshTicker: { delayMs: 0 }, + terminal, + }); + + await waitFor(() => plainTerminalOutput(terminal.output()).includes('ctx ?/500k')); + terminal.input('start the work'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + await waitFor(() => driver.diagnosticsCalls >= 1); + // The failed pull must not blank the segment or surface an error — a read + // that could not reach the snapshot costs nothing. + assert.equal(plainTerminalOutput(terminal.output()).includes('host not ready'), false); + assert.ok(plainTerminalOutput(terminal.output()).includes('ctx ?/500k')); + + driver.endTurn(); + await waitFor(() => terminal.progressStates.at(-1) === false); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('hydrates a resumed background Bash card from durable shell-run state', async () => { const terminal = new FakeTerminal(); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -8831,6 +8917,52 @@ class SlowStopDriver extends FakeSessionDriver { } } +// #4545: a turn parked after tool_start, with a Host-backed context snapshot +// query, so tests can watch the statusline ctx segment move mid-turn. +class LiveCtxDriver extends FakeSessionDriver { + diagnostics: ContextDiagnostics = { status: 'unavailable', reason: 'no_completed_request' }; + diagnosticsError: Error | undefined; + diagnosticsCalls = 0; + private releaseTurn: (() => void) | null = null; + + preparePrompt(prompt: string): Promise { + return prepareTestPrompt(this, prompt); + } + + async *promptEvents(_prompt: string): AsyncIterable { + yield { + type: 'tool_start', + id: 'event-tool-start', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Bash', + args: { command: 'npm test' }, + }; + await new Promise((resolve) => { + this.releaseTurn = resolve; + }); + yield { + type: 'complete', + id: 'event-complete', + turnId: 'turn-1', + ts: 2, + stopReason: 'end_turn', + }; + } + + endTurn(): void { + this.releaseTurn?.(); + this.releaseTurn = null; + } + + async getContextDiagnostics(): Promise { + this.diagnosticsCalls += 1; + if (this.diagnosticsError) throw this.diagnosticsError; + return this.diagnostics; + } +} + class ToolOutputDriver extends FakeSessionDriver { preparePrompt(prompt: string): Promise { return prepareTestPrompt(this, prompt); diff --git a/packages/cli/src/__tests__/tui-context-refresh.test.ts b/packages/cli/src/__tests__/tui-context-refresh.test.ts new file mode 100644 index 0000000000..2bca471f4c --- /dev/null +++ b/packages/cli/src/__tests__/tui-context-refresh.test.ts @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { SessionEvent } from '@maka/core/events'; +import { + CTX_REFRESH_DEBOUNCE_MS, + createCtxRefresher, + isCtxRefreshRelevantEvent, + scheduleCtxRefreshTimeout, +} from '../tui-context-refresh.js'; + +function toolStartEvent(): SessionEvent { + return { + type: 'tool_start', + id: 'event-tool-start', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Bash', + args: { command: 'npm test' }, + }; +} + +function textDeltaEvent(): SessionEvent { + return { + type: 'text_delta', + id: 'event-text', + turnId: 'turn-1', + ts: 1, + messageId: 'message-1', + text: 'hello', + }; +} + +interface ScheduledRun { + callback: () => void; + delayMs: number; +} + +function createFakeScheduler() { + let pending: ScheduledRun | undefined; + const schedule = (callback: () => void, delayMs: number) => { + pending = { callback, delayMs }; + return () => { + if (pending?.callback === callback) pending = undefined; + }; + }; + return { + schedule, + hasPending: () => pending !== undefined, + pendingDelayMs: () => pending?.delayMs, + flush: () => { + const run = pending; + pending = undefined; + run?.callback(); + }, + }; +} + +describe('isCtxRefreshRelevantEvent', () => { + test('matches the desktop inspector trace-relevant set', () => { + for (const type of [ + 'tool_start', + 'tool_result', + 'token_usage', + 'provider_retry', + 'error', + 'complete', + 'abort', + ]) { + assert.equal(isCtxRefreshRelevantEvent({ type } as SessionEvent), true, type); + } + }); + + test('streaming deltas never schedule a query', () => { + for (const type of ['text_delta', 'thinking_delta', 'tool_output', 'queue_update']) { + assert.equal(isCtxRefreshRelevantEvent({ type } as SessionEvent), false, type); + } + }); +}); + +describe('createCtxRefresher', () => { + test('a relevant event runs one query after the debounce delay', async () => { + const scheduler = createFakeScheduler(); + const queryCalls: number[] = []; + const applied: string[] = []; + const refresher = createCtxRefresher({ + query: () => { + queryCalls.push(1); + return Promise.resolve('snapshot'); + }, + apply: (result) => applied.push(result), + delayMs: CTX_REFRESH_DEBOUNCE_MS, + schedule: scheduler.schedule, + }); + + refresher.observe(toolStartEvent()); + assert.equal(scheduler.pendingDelayMs(), CTX_REFRESH_DEBOUNCE_MS); + assert.equal(queryCalls.length, 0); + + scheduler.flush(); + await Promise.resolve(); + assert.deepEqual(queryCalls, [1]); + assert.deepEqual(applied, ['snapshot']); + }); + + test('a burst of events coalesces into one query, at the last event', async () => { + const scheduler = createFakeScheduler(); + let queries = 0; + const refresher = createCtxRefresher({ + query: () => { + queries += 1; + return Promise.resolve('snapshot'); + }, + apply: () => {}, + delayMs: 400, + schedule: scheduler.schedule, + }); + + refresher.observe(toolStartEvent()); + refresher.observe(toolStartEvent()); + refresher.observe(toolStartEvent()); + assert.equal(scheduler.hasPending(), true); + + scheduler.flush(); + await Promise.resolve(); + assert.equal(queries, 1); + }); + + test('only the latest issued query may apply', async () => { + const scheduler = createFakeScheduler(); + const queries: Array>> = []; + const applied: string[] = []; + const refresher = createCtxRefresher({ + query: () => { + const query = deferred(); + queries.push(query); + return query.promise; + }, + apply: (result) => applied.push(result), + delayMs: 400, + schedule: scheduler.schedule, + }); + + refresher.observe(toolStartEvent()); + scheduler.flush(); + refresher.observe(toolStartEvent()); + scheduler.flush(); + assert.equal(queries.length, 2); + + // The newer answer lands first; the older read then resolves late and + // must not overwrite it with its staler snapshot. + queries[1]!.resolve('newer'); + await Promise.resolve(); + assert.deepEqual(applied, ['newer']); + queries[0]!.resolve('older'); + await Promise.resolve(); + assert.deepEqual(applied, ['newer']); + }); + + test('a failed query leaves the last value standing and later events still refresh', async () => { + const scheduler = createFakeScheduler(); + const queries: Array>> = []; + const applied: string[] = []; + const refresher = createCtxRefresher({ + query: () => { + const query = deferred(); + queries.push(query); + return query.promise; + }, + apply: (result) => applied.push(result), + delayMs: 400, + schedule: scheduler.schedule, + }); + + refresher.observe(toolStartEvent()); + scheduler.flush(); + queries[0]!.reject(new Error('host not ready')); + // The rejection is consumed, not raised. + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(applied, []); + + refresher.observe(toolStartEvent()); + scheduler.flush(); + queries[1]!.resolve('recovered'); + await Promise.resolve(); + assert.deepEqual(applied, ['recovered']); + }); + + test('cancel drops a scheduled query and retires an in-flight one', async () => { + const scheduler = createFakeScheduler(); + const inFlight = deferred(); + const applied: string[] = []; + const refresher = createCtxRefresher({ + query: () => inFlight.promise, + apply: (result) => applied.push(result), + delayMs: 400, + schedule: scheduler.schedule, + }); + + // Scheduled but not yet run: cancel prevents the query entirely. + refresher.observe(toolStartEvent()); + refresher.cancel(); + assert.equal(scheduler.hasPending(), false); + + // In-flight: a resolution after cancel belongs to a retired read. + refresher.observe(toolStartEvent()); + scheduler.flush(); + refresher.cancel(); + inFlight.resolve('stale'); + await Promise.resolve(); + assert.deepEqual(applied, []); + }); + + test('irrelevant events never schedule a query', () => { + const scheduler = createFakeScheduler(); + const refresher = createCtxRefresher({ + query: () => Promise.resolve('snapshot'), + apply: () => {}, + delayMs: 400, + schedule: scheduler.schedule, + }); + + refresher.observe(textDeltaEvent()); + assert.equal(scheduler.hasPending(), false); + }); + + test('the default timeout scheduler fires and cancels for real', async () => { + const fired = deferred(); + const cancel = scheduleCtxRefreshTimeout(() => fired.resolve(), 0); + cancel(); + // A cancelled zero-delay timer must not fire on the next tick. + await new Promise((resolve) => setTimeout(resolve, 5)); + let firedFlag = false; + void fired.promise.then(() => { + firedFlag = true; + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(firedFlag, false); + + const fired2 = deferred(); + scheduleCtxRefreshTimeout(() => fired2.resolve(), 0); + await fired2.promise; + }); +}); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index b025e7c572..ad3762e493 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -133,6 +133,11 @@ import { McpManagementOverlay } from './pi-tui-mcp-status.js'; import type { TuiMcpManagement } from './tui-mcp-control.js'; import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js'; import { createShellRunHydrationController } from './shell-run-hydration.js'; +import { + CTX_REFRESH_DEBOUNCE_MS, + createCtxRefresher, + scheduleCtxRefreshTimeout, +} from './tui-context-refresh.js'; import { sessionStatusBadge } from './tui-session-status.js'; import { AttentionController, @@ -225,6 +230,15 @@ export interface MakaPiTuiInput { now?: () => number; schedule?: (callback: () => void, intervalMs: number) => () => void; }; + /** + * Debounce scheduling for the live ctx refresh (#4545). Injectable so tests + * drive the timer deterministically; defaults to CTX_REFRESH_DEBOUNCE_MS + + * an unref'd setTimeout. + */ + ctxRefreshTicker?: { + delayMs?: number; + schedule?: (callback: () => void, delayMs: number) => () => void; + }; subscribeSessionTitleChanges?: (listener: (sessionId: string) => void) => () => void; subscribeShellRunUpdates?: (listener: (update: ShellRunUpdate) => void) => () => void; /** @@ -745,6 +759,39 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { schedule: input.shellRunTicker?.schedule, }); + // Live ctx refresh (#4545): the end-of-turn token_usage event moves the + // statusline once per turn, but the Host's latest-context snapshot moves at + // every settled provider request. Pull it on the desktop inspector's signal + // (tui-context-refresh.ts) so a long turn shows the context filling as it + // fills. The query is a plain read on the driver — deliberately not routed + // through runControl, whose serial lock exists for mutations. + const getContextDiagnostics = input.driver.getContextDiagnostics?.bind(input.driver); + const ctxRefresher = getContextDiagnostics + ? createCtxRefresher({ + query: async () => { + const sessionId = input.driver.getSessionId(); + const diagnostics = await getContextDiagnostics(); + return { sessionId, diagnostics }; + }, + apply: ({ sessionId, diagnostics }) => { + // The session moved under the query (switch, /new): its usage was + // rebuilt from stored messages, and a pre-switch snapshot must not + // overwrite it. + if (closed || input.driver.getSessionId() !== sessionId) return; + if (diagnostics.status !== 'available') return; + const { inputTokens, contextWindow } = diagnostics; + if (inputTokens === undefined || contextWindow === undefined || contextWindow <= 0) + return; + // Same formula the token_usage path uses (#1067), from the same + // settled request — the two cannot disagree. + state.usage.contextRemaining = Math.max(0, contextWindow - inputTokens); + requestRender(); + }, + delayMs: input.ctxRefreshTicker?.delayMs ?? CTX_REFRESH_DEBOUNCE_MS, + schedule: input.ctxRefreshTicker?.schedule ?? scheduleCtxRefreshTimeout, + }) + : undefined; + // ── Explicit skill invocation (#1148) ──────────────────────────────────── // One cached list feeds autocomplete, the `/skill` picker, and the editor's // sync highlight validator. The cache is keyed by cwd (project-level skill @@ -934,6 +981,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { unsubscribeTranscriptReplacements(); shellRunHydration.dispose(); shellRunElapsedTicker.dispose(); + ctxRefresher?.cancel(); stopTurnElapsedTicker(); setTaskbarProgress(false); // Drop the busy / attention title marker so the tab is not handed back to @@ -1462,6 +1510,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } applyMakaSessionEventToTranscript(state, event); + ctxRefresher?.observe(event); if (event.type === 'error') attention.attentionNeeded(); if ( permissionResponseInFlightRequestId !== null && @@ -2473,6 +2522,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { try { for await (const event of input.driver.resumeLatest()) { applyMakaSessionEventToTranscript(state, event); + ctxRefresher?.observe(event); shellRunElapsedTicker.sync(); syncUserQuestionOverlay(); requestRender(); diff --git a/packages/cli/src/tui-context-refresh.ts b/packages/cli/src/tui-context-refresh.ts new file mode 100644 index 0000000000..98be3a6534 --- /dev/null +++ b/packages/cli/src/tui-context-refresh.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionEvent } from '@maka/core/events'; + +/** + * How full the context is right now, re-asked mid-turn (#4545). + * + * The statusline ctx segment historically moved once per turn, when the + * end-of-turn `token_usage` event landed — a long agentic turn burned context + * for minutes 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 the TUI pulls it on the same + * signal desktop uses instead of growing a parallel push event. + */ + +/** + * When a live session's context snapshot is worth re-reading. Mirrors the + * desktop inspector's TRACE_RELEVANT_EVENT_TYPES + * (apps/desktop/src/renderer/session-trace-refresh.ts): the snapshot is + * committed at each settled provider request, so the events that follow one — + * tool boundaries above all — are the moments the answer can have changed. + * That is deliberately not "every event": a streaming turn emits text deltas + * continuously, and none of them moves the snapshot. + */ +const CTX_REFRESH_EVENT_TYPES: ReadonlySet = new Set([ + 'tool_start', + 'tool_result', + 'token_usage', + 'provider_retry', + 'error', + 'complete', + 'abort', +]); + +export function isCtxRefreshRelevantEvent(event: SessionEvent): boolean { + return CTX_REFRESH_EVENT_TYPES.has(event.type); +} + +/** + * Long enough to absorb a step boundary's event burst, short enough to feel + * live. Same value and rationale as the desktop inspector's + * TRACE_REFRESH_DEBOUNCE_MS. + */ +export const CTX_REFRESH_DEBOUNCE_MS = 400; + +type CancelScheduled = () => void; + +export interface CtxRefresher { + /** Records an event; schedules a query when the event can have moved the snapshot. */ + observe(event: SessionEvent): void; + /** Drops a scheduled query and retires any in-flight one. */ + cancel(): void; +} + +/** + * Coalesces a burst of refresh-worthy events into one query, and lets only + * the latest issued query apply. A query that resolves after a newer one was + * issued — or after `cancel` retired it — is dropped: the answer a slow read + * brings back describes an older snapshot than the one already shown, and a + * torn-down session is not owed an update at all. A failed query leaves the + * last value standing: it is still the newest answer anyone has. + * + * The scheduler is injected so the policy is testable without a wall clock, + * and follows the CLI's ticker convention: schedule returns the cancel. + */ +export function createCtxRefresher(input: { + query: () => Promise; + apply: (result: T) => void; + delayMs: number; + schedule: (callback: () => void, delayMs: number) => CancelScheduled; +}): CtxRefresher { + let cancelScheduled: CancelScheduled | undefined; + let revision = 0; + const dropScheduled = (): void => { + cancelScheduled?.(); + cancelScheduled = undefined; + }; + const run = (): void => { + cancelScheduled = undefined; + const requestRevision = ++revision; + void input.query().then( + (result) => { + if (requestRevision !== revision) return; + input.apply(result); + }, + () => {}, + ); + }; + return { + observe(event) { + if (!isCtxRefreshRelevantEvent(event)) return; + // Restart rather than stack: the last event of a burst is the one whose + // snapshot the reader wants, and an earlier timer would read before it. + dropScheduled(); + cancelScheduled = input.schedule(run, input.delayMs); + }, + cancel() { + dropScheduled(); + revision += 1; + }, + }; +} + +/** Default one-shot timer; unref'd so a pending refresh never holds the CLI open. */ +export function scheduleCtxRefreshTimeout(callback: () => void, delayMs: number): CancelScheduled { + const handle = setTimeout(callback, delayMs); + handle.unref(); + return () => clearTimeout(handle); +} diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index b9bcf2ee82..1ab7851129 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -48,6 +48,7 @@ export const EXCLUDED_TUI_FILES = [ 'packages/cli/src/tui-ansi.ts', 'packages/cli/src/tui-autocomplete-layout.ts', 'packages/cli/src/tui-clipboard.ts', + 'packages/cli/src/tui-context-refresh.ts', 'packages/cli/src/tui-copy-catalog.ts', 'packages/cli/src/tui-diff.ts', 'packages/cli/src/tui-mcp-control.ts',