Skip to content

Commit 3a860a6

Browse files
authored
fix(tui): move ACP session usage into footer context (#441)
## Summary - route ACP `SessionUpdateInfo::Usage` updates into the footer context segment instead of rendering the verbose `Session usage: ...` history line - use structured ACP session usage as the primary context source and only fall back to provider-specific transcript parsing when no ACP usage updates have arrived - update transcript replay, view-only transcript behavior, docs, and snapshots so the footer shows values like `Context 16% (42.6K)` consistently ## Why The new ACP usage history render was technically correct but too noisy in the transcript and interrupted more important output. The footer already has a dedicated context surface, so this change keeps the usage signal visible without adding extra transcript clutter. ## Impact Users on the ACP-backed Nori CLI now see context usage in the footer instead of as a standalone history item, and replayed sessions restore that footer state through the structured protocol path. ## Test Plan - `just fmt` - `cargo test -p nori-protocol` - `cargo test -p codex-acp` - `cargo test -p nori-tui` - `cargo test` - `cargo build --bin nori` - `cargo test -p tui-pty-e2e` - manual tmux verification with `elizacp`: launch `nori`, assert prompt appears, submit `hello`, assert prompt returns ## Notes - originally developed as a short stacked follow-up, then rebased onto the latest `main` after the parent work landed
1 parent 8cbf266 commit 3a860a6

27 files changed

Lines changed: 218 additions & 48 deletions

codex-rs/acp/docs.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ The live backend path in `user_input.rs`, `submit_and_ops.rs`, `spawn_and_relay.
8989

9090
Metadata notifications that ACP permits while idle are treated as session-owned rather than request-owned. `AvailableCommandsUpdate`, `CurrentModeUpdate`, `ConfigOptionUpdate`, `SessionInfoUpdate`, and `UsageUpdate` no longer produce "no request is active" warnings; instead the reducer persists the latest values and forwards normalized `ClientEvent`s downstream.
9191

92-
`session/load` replay also preserves more session context than before. User-side `MessageDelta { stream: User, .. }` values are reassembled into `ReplayEntry::UserMessage`, while `SessionUpdateInfo` notes pass through unchanged so restored transcripts can show lightweight session metadata alongside the loaded conversation.
92+
`session/load` replay also preserves more session context than before. User-side `MessageDelta { stream: User, .. }` values are reassembled into `ReplayEntry::UserMessage`, while `SessionUpdateInfo` notes pass through unchanged. For usage updates, that replay path now restores the structured footer context state without needing to re-render the verbose message in history.
9393

9494
**Custom Agent TOML Schema** (`config/types/mod.rs`):
9595

@@ -494,9 +494,9 @@ Codex `token_count` events contain two token usage objects with different semant
494494
| Object | Meaning | Used For |
495495
|--------|---------|----------|
496496
| `total_token_usage` | Cumulative billing counter across ALL API calls in the session; grows unboundedly | `input_tokens`, `output_tokens`, `cached_tokens` fields (the "Tokens" footer segment) |
497-
| `last_token_usage` | Tokens from the most recent API call only; represents actual context window fill | `last_context_tokens` field (the "Context: XK (Y%)" footer segment) |
497+
| `last_token_usage` | Tokens from the most recent API call only; represents actual context window fill | `last_context_tokens` field used by transcript-discovery fallback for the "Context Y% (XK)" footer segment |
498498

499-
Using `total_token_usage.input_tokens` for context window percentage would produce nonsensical results (e.g., 995K tokens for a 258K context window) because the cumulative counter sums across all turns. The `last_token_usage.input_tokens` correctly reflects how full the context window is for the current turn. When `last_token_usage` is absent (older transcript formats), `last_context_tokens` is `None` and the context percentage is not displayed.
499+
Using `total_token_usage.input_tokens` for context window percentage would produce nonsensical results (e.g., 995K tokens for a 258K context window) because the cumulative counter sums across all turns. The `last_token_usage.input_tokens` correctly reflects how full the context window is for the current turn. When ACP `UsageUpdate` events are present, the TUI prefers those live session values for the footer; transcript parsing remains a fallback for older agents/sessions where `UsageUpdate` is absent. When `last_token_usage` is absent (older transcript formats), `last_context_tokens` is `None` and the transcript fallback does not display a context percentage.
500500

501501
**Claude Code Streaming Deduplication:**
502502

codex-rs/acp/src/backend/transcript.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ mod tests {
357357
kind: nori_protocol::SessionUpdateKind::SessionInfo,
358358
message: "Session info updated: title=\"Resume chat\"".into(),
359359
hint: None,
360+
usage: None,
360361
}),
361362
nori_protocol::ClientEvent::MessageDelta(nori_protocol::MessageDelta {
362363
stream: nori_protocol::MessageStream::Answer,
@@ -374,6 +375,7 @@ mod tests {
374375
kind: nori_protocol::SessionUpdateKind::SessionInfo,
375376
message: "Session info updated: title=\"Resume chat\"".into(),
376377
hint: None,
378+
usage: None,
377379
}),
378380
nori_protocol::ClientEvent::ReplayEntry(
379381
nori_protocol::ReplayEntry::AssistantMessage {
@@ -392,6 +394,11 @@ mod tests {
392394
kind: nori_protocol::SessionUpdateKind::Usage,
393395
message: "Session usage: 128 / 4096 tokens".into(),
394396
hint: None,
397+
usage: Some(nori_protocol::session_runtime::SessionUsageState {
398+
used_tokens: 128,
399+
total_tokens: 4096,
400+
cost_display: None,
401+
}),
395402
},
396403
),
397404
})]);
@@ -405,6 +412,11 @@ mod tests {
405412
kind: nori_protocol::SessionUpdateKind::Usage,
406413
message: "Session usage: 128 / 4096 tokens".into(),
407414
hint: None,
415+
usage: Some(nori_protocol::session_runtime::SessionUsageState {
416+
used_tokens: 128,
417+
total_tokens: 4096,
418+
cost_display: None,
419+
}),
408420
},
409421
)]
410422
);

codex-rs/nori-protocol/docs.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Path: @/codex-rs/nori-protocol
66

77
- Defines the normalized `ClientEvent` protocol that sits between raw ACP session updates (from `agent-client-protocol-schema`) and the TUI rendering layer. All ACP tool calls, messages, plans, approvals, and replay entries are transformed into this crate's types before reaching the TUI.
88
- The `ClientEventNormalizer` is the stateful entry point: it accepts `acp::SessionUpdate` and `acp::RequestPermissionRequest` values and emits `Vec<ClientEvent>`.
9-
- Session-scoped ACP metadata that does not deserve bespoke widgets yet is normalized into `ClientEvent::SessionUpdateInfo`, giving the rest of the stack one minimal rendering/replay path for mode, config, session-info, and usage updates.
9+
- Session-scoped ACP metadata is normalized into `ClientEvent::SessionUpdateInfo`, giving the rest of the stack one minimal rendering/replay path for mode, config, and session-info updates while still letting usage updates carry structured footer state.
1010
- Single-file crate (`lib.rs`) with no submodules.
1111

1212
### How it fits into the larger codebase
@@ -29,7 +29,8 @@ agent_client_protocol_schema::SessionUpdate
2929
- **`SessionRuntime` support types** in `session_runtime.rs` define the reducer-owned ACP runtime model used by `codex-acp`: `SessionPhase`, `PersistedSessionState`, `ActiveRequestState`, `OpenMessage`, and `QueuedPrompt`. These types let the backend treat prompt turns, `session/load`, queued prompts, and ownership of tool/approval updates as one ordered state machine instead of reconstructing turn state from racing tasks.
3030
- **Session update normalization** keeps the first pass intentionally small:
3131
- `UserMessageChunk` becomes `MessageDelta { stream: User, .. }`, which lets replay paths reconstruct visible user history during `session/load`.
32-
- `CurrentModeUpdate`, `ConfigOptionUpdate`, `SessionInfoUpdate`, and `UsageUpdate` become `SessionUpdateInfo`, a display-oriented summary with a stable `kind`.
32+
- `CurrentModeUpdate`, `ConfigOptionUpdate`, and `SessionInfoUpdate` become lightweight `SessionUpdateInfo` summaries.
33+
- `UsageUpdate` also becomes `SessionUpdateInfo`, but the usage variant additionally carries `SessionUsageState` so the TUI can update footer context without reparsing the display string.
3334
- **Persisted session metadata** now includes `session_info` and `session_usage` alongside available commands, current mode, and config options. `codex-acp` owns persistence, but these structs live here so the reducer and replay pipeline share one runtime model.
3435
- **`is_generic_tool_call()`** gates initial `ToolCall` emission: tool calls with no `raw_input`, no `locations`, empty `content`, and no `/` in the title are suppressed (return empty `Vec`). The normalizer still records them internally so that later attributed `ToolCallUpdate` messages can refine the existing call without forcing the TUI to render a placeholder cell first.
3536
- **Invocation priority cascade** in `invocation_from_tool_call()` resolves what the tool is doing, in priority order:
@@ -48,7 +49,7 @@ agent_client_protocol_schema::SessionUpdate
4849
### Things to Know
4950

5051
- The `is_generic_tool_call()` filter means the normalizer is not 1:1 with incoming events. Initial `ToolCall` messages that are sufficiently sparse are silently dropped, but later `ToolCallUpdate` messages still become visible `ToolSnapshot`s even if no initial `ToolCall` ever arrived.
51-
- `SessionUpdateInfo` is intentionally lossy. It exists to stop valid ACP session updates from disappearing while the UI remains lightweight; richer dedicated widgets can still be layered on later without changing ACP reducer semantics.
52+
- `SessionUpdateInfo` stays intentionally lightweight, but it is no longer fully lossy: the `Usage` variant also carries structured `SessionUsageState` so replay and live footer updates can share the same path.
5253
- The location fallback (tier 4) only handles `Read` and `Search` kinds. Edit/Delete/Move with locations but no `raw_input` return `None` from the normalizer and fall through to the TUI's location-path display fallback, avoiding creation of empty-diff `FileOperations` that would route to `PatchHistoryCell`.
5354
- `sanitize_title()` is a two-pass operation: first strips the `[current working directory ...]` bracket, then strips trailing `(description)` parenthetical. The parenthetical strip only fires after a cwd bracket was found, because Gemini appends descriptions after the cwd metadata.
5455
- Shell wrapper detection (`is_shell_wrapper()`) recognizes `bash`, `sh`, `zsh`, `fish`, `pwsh`, and `powershell` with `-c` or `-lc` flags. When a 3-element command array matches this pattern, only the script portion is extracted as the command string.

codex-rs/nori-protocol/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ pub struct SessionUpdateInfo {
7676
pub kind: SessionUpdateKind,
7777
pub message: String,
7878
pub hint: Option<String>,
79+
#[serde(default, skip_serializing_if = "Option::is_none")]
80+
pub usage: Option<session_runtime::SessionUsageState>,
7981
}
8082

8183
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -959,6 +961,7 @@ fn session_update_info_from_current_mode(update: &acp::CurrentModeUpdate) -> Ses
959961
kind: SessionUpdateKind::CurrentMode,
960962
message: format!("ACP mode changed to {}", update.current_mode_id),
961963
hint: None,
964+
usage: None,
962965
}
963966
}
964967

@@ -978,6 +981,7 @@ fn session_update_info_from_config_options(update: &acp::ConfigOptionUpdate) ->
978981
kind: SessionUpdateKind::ConfigOptions,
979982
message,
980983
hint: None,
984+
usage: None,
981985
}
982986
}
983987

@@ -1001,6 +1005,7 @@ fn session_update_info_from_session_info(update: &acp::SessionInfoUpdate) -> Ses
10011005
kind: SessionUpdateKind::SessionInfo,
10021006
message,
10031007
hint: None,
1008+
usage: None,
10041009
}
10051010
}
10061011

@@ -1028,6 +1033,14 @@ fn session_update_info_from_usage(update: &acp::UsageUpdate) -> SessionUpdateInf
10281033
kind: SessionUpdateKind::Usage,
10291034
message,
10301035
hint: None,
1036+
usage: Some(session_runtime::SessionUsageState {
1037+
used_tokens: update.used as i64,
1038+
total_tokens: update.size as i64,
1039+
cost_display: update
1040+
.cost
1041+
.as_ref()
1042+
.map(|cost| format!("{:.2} {}", cost.amount, cost.currency)),
1043+
}),
10311044
}
10321045
}
10331046

@@ -2168,6 +2181,14 @@ mod tests {
21682181
};
21692182

21702183
assert_eq!(info.kind, SessionUpdateKind::Usage);
2184+
assert_eq!(
2185+
info.usage,
2186+
Some(session_runtime::SessionUsageState {
2187+
used_tokens: 128,
2188+
total_tokens: 4096,
2189+
cost_display: Some("0.42 USD".to_string()),
2190+
})
2191+
);
21712192
assert_eq!(
21722193
info.message,
21732194
"Session usage: 128 / 4096 tokens, cost 0.42 USD"

codex-rs/nori-protocol/src/session_runtime.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use std::collections::HashSet;
99
use std::collections::VecDeque;
1010

1111
use agent_client_protocol_schema as acp;
12+
use serde::Deserialize;
13+
use serde::Serialize;
1214

1315
use crate::AgentCommandInfo;
1416
use crate::PlanSnapshot;
@@ -163,7 +165,7 @@ pub struct SessionInfoState {
163165
pub updated_at: Option<String>,
164166
}
165167

166-
#[derive(Debug, Clone, PartialEq, Eq)]
168+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167169
pub struct SessionUsageState {
168170
pub used_tokens: i64,
169171
pub total_tokens: i64,

codex-rs/tui/docs.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ The main event loop in `app/mod.rs` processes:
4747
2. **Backend events** from ACP: `BackendEvent::Client` carries normalized `nori_protocol::ClientEvent` session data, while `BackendEvent::Control` carries shared control-plane events
4848
3. **App events** for state changes (agent selection, config updates)
4949

50-
The client-event stream now also includes lightweight ACP session metadata summaries. Rather than adding dedicated UI chrome for every ACP session update, the first-pass TUI behavior renders `ClientEvent::SessionUpdateInfo` as ordinary info/history cells and includes the same text in the view-only transcript.
50+
The client-event stream now also includes lightweight ACP session metadata summaries. Most `ClientEvent::SessionUpdateInfo` values still render as ordinary info/history cells, but usage updates are handled specially: they update the footer context segment and are omitted from both live history cells and the view-only transcript.
5151

5252
The chat interface is managed by the `chatwidget/` module (`chatwidget/mod.rs` + submodules), which handles:
5353
- User input composition with multi-line editing
@@ -415,7 +415,7 @@ The card always shows: version, directory, agent, skillset (Nori profile). Optio
415415
|---------|-----------|---------|
416416
| Task summary | `prompt_summary` present | "Task: Fix auth bug" |
417417
| Approval mode | `approval_mode_label` present | "approvals: Agent" |
418-
| Context line | `context_window_percent` present, with or without token data | "Context: 77.0K (27%)" or just "42%" |
418+
| Context line | `context_window_percent` present, with or without token data | "Context 27% (77.0K)" or just "Context 42%" |
419419
| Token totals | `token_breakdown` has non-zero total | "Tokens: 123K total (32.0K cached)" |
420420

421421
The Tokens section renders if either `token_breakdown` has a non-zero total OR `context_window_percent` is present. This means context window percentage from the live API (`TokenUsageInfo`) can appear even before transcript token data is available.
@@ -598,7 +598,7 @@ The footer displays configurable segments, each of which can be enabled/disabled
598598
| Git Branch | `git_branch` | Current branch name with ⎇ symbol (yellow for main repo, orange for worktree) |
599599
| Worktree Name | `worktree_name` | "Worktree: {name}" (light red) when running in an auto-worktree session -- the immutable directory name, distinct from the git branch which gets renamed after the first prompt |
600600
| Git Stats | `git_stats` | Lines added/removed in current session |
601-
| Context Window | `context` | "Context: 34K (27%)" when running within an agent environment |
601+
| Context Window | `context` | "Context 27% (34K)" when running within an agent environment |
602602
| Approval Mode | `approval_mode` | "Approvals: Agent/Full Access/Read Only" |
603603
| Nori Profile | `nori_profile` | "Skillset: name" for one active skillset, "Skillsets: a, b" for multiple, hidden when none are active. Uses `active_skillsets` from `SystemInfo` (populated by `nori-skillsets list-active`). |
604604
| Nori Version | `nori_version` | "Skillsets v<version>" |
@@ -614,6 +614,7 @@ git_stats = false
614614
All segments are enabled by default. The order of segments in the footer is fixed (cannot be reordered via config).
615615

616616
Token data flows from `TranscriptLocation.token_breakdown` (provided by `codex_acp::discover_transcript_for_agent_with_message()`) through `FooterProps` to the footer renderer. The breakdown includes separate input, output, and cached token counts for accurate usage reporting.
617+
Footer context usage is sourced in priority order: ACP `SessionUpdateInfo { kind: Usage, usage: Some(..) }` updates drive the footer when available, while `TranscriptLocation.token_breakdown` remains the provider-specific fallback for older sessions or agents that do not emit ACP usage updates.
617618

618619
The prompt summary flows from the ACP backend as an `EventMsg::PromptSummary` event, handled by `ChatWidget::on_prompt_summary()`, which propagates it down: `ChatWidget` -> `BottomPane::set_prompt_summary()` -> `ChatComposer::set_prompt_summary()` -> `FooterProps.prompt_summary` -> `footer_segments()` renderer.
619620

codex-rs/tui/src/bottom_pane/chat_composer/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ pub(crate) struct ChatComposer {
120120
footer_mode: FooterMode,
121121
footer_hint_override: Option<Vec<(String, String)>>,
122122
context_window_percent: Option<i64>,
123+
session_usage: Option<nori_protocol::session_runtime::SessionUsageState>,
123124
system_info: Option<crate::system_info::SystemInfo>,
124125
/// The approval mode label to display in the footer (e.g., "Read Only", "Agent", "Full Access").
125126
approval_mode_label: Option<String>,
@@ -179,6 +180,7 @@ impl ChatComposer {
179180
footer_mode: FooterMode::ShortcutSummary,
180181
footer_hint_override: None,
181182
context_window_percent: None,
183+
session_usage: None,
182184
system_info: None,
183185
approval_mode_label: None,
184186
vim_enter_behavior: codex_acp::config::VimEnterBehavior::Off,
@@ -369,6 +371,13 @@ impl ChatComposer {
369371
}
370372
}
371373

374+
pub(crate) fn set_session_usage(
375+
&mut self,
376+
usage: Option<nori_protocol::session_runtime::SessionUsageState>,
377+
) {
378+
self.session_usage = usage;
379+
}
380+
372381
pub(crate) fn set_system_info(&mut self, info: crate::system_info::SystemInfo) {
373382
self.system_info = Some(info);
374383
}

codex-rs/tui/src/bottom_pane/chat_composer/rendering.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ impl ChatComposer {
7979
})
8080
})
8181
});
82+
let (context_tokens, context_window_percent) =
83+
if let Some(session_usage) = &self.session_usage {
84+
(
85+
Some(session_usage.used_tokens).filter(|&tokens| tokens > 0),
86+
(session_usage.total_tokens > 0).then(|| {
87+
session_usage
88+
.used_tokens
89+
.saturating_mul(100)
90+
.saturating_div(session_usage.total_tokens)
91+
.clamp(0, 100)
92+
}),
93+
)
94+
} else {
95+
(context_tokens, context_window_percent)
96+
};
8297

8398
FooterProps {
8499
mode: self.footer_mode(),

0 commit comments

Comments
 (0)