fix(tui): repaint the header context window on a /model switch

After `/model`, the header kept the previous model's window ("15K /
1.0M" on a 262K model) until the next turn or a restart. The pager
caches the window inside its context snapshot (streaming `totalTokens`
updates refresh it from the catalog) and nothing refreshed the cache
when the session's model changed. Comparing cached numbers against
catalog windows cannot tell a catalog-derived total from a shell
effective limit (KIGI_DEBUG_CONTEXT_WINDOW, header upgrades), so the
shell, which installs the effective window, now reports it:

- kigi-shell: `handle_set_session_model` returns the installed window;
  `model_switch::apply` stamps it as `totalContextTokens` in the
  `session/set_model` response meta and carries it on the
  `ModelChanged` broadcast (`context_window`, serde-default: old pagers
  ignore it, old leaders omit it). The response meta previously
  serialized the actor's inner `Result` and reported success on an
  actor failure; that error now propagates.
- kigi-tui: `Effect::SwitchModel` reads the meta; `SwitchModelComplete`
  carries `Ok(Option<u64>)`; the completion handler and the follower
  `ModelChanged` arm call `AgentView::apply_context_window`, which
  re-derives the snapshot (pct, free tokens) and reports whether it
  changed so a follower redraws on a window-only change.
- Tests: the bare `/model <name>` dispatch path asserts the snapshot and
  the drawn header; follower with/without the field; same-model new
  window redraw; actor returns the window; serde round trip.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 00:15:14 -04:00
1 parent ff92a00fea
commit b957e12370
16 files changed
+288 -27

No files matched your search

@@ -812,6 +812,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
XaiSessionUpdate::ModelChanged {
model_id,
reasoning_effort,
context_window,
} => {
if agent.session.model_switch_pending {
tracing::debug!(
@@ -847,6 +848,8 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
.session
.models
.set_current(new_model_id.clone(), effort);
let window_changed =
context_window.is_some_and(|total| agent.apply_context_window(total));
agent.session.user_model_preference = Some(new_model_id.clone());
let resolved_effort = agent.session.models.reasoning_effort;
let actually_changed =
@@ -858,7 +861,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
"ModelChanged broadcast applied (remote switch)"
);
}
actually_changed
actually_changed || window_changed
}
XaiSessionUpdate::MemoryFiles { files } => {
let entries = crate::views::memory_modal::build_entries(files);
@@ -1701,12 +1701,14 @@ pub(super) fn model_changed_ext(
session_id: &str,
model_id: &str,
reasoning_effort: Option<&str>,
context_window: Option<u64>,
) -> acp::ExtNotification {
let payload = SessionNotification {
session_id: acp::SessionId::new(session_id),
update: XaiSessionUpdate::ModelChanged {
model_id: model_id.to_string(),
reasoning_effort: reasoning_effort.map(String::from),
context_window,
},
meta: None,
};
@@ -1723,6 +1725,7 @@ pub(super) fn model_changed_ext_with_event(
update: XaiSessionUpdate::ModelChanged {
model_id: model_id.to_string(),
reasoning_effort: None,
context_window: None,
},
meta: Some(serde_json::json!({ "eventId" : event_id })),
};
@@ -110,6 +110,70 @@
);
}
fn seed_windowed_models(agent: &mut AgentView) {
for (slug, window) in [("wide", 1_000_000u64), ("narrow", 262_144)] {
let mut info = make_model_info(slug);
info.meta = serde_json::json!({ "totalContextTokens": window })
.as_object()
.cloned();
agent
.session
.models
.available
.insert(acp::ModelId::new(std::sync::Arc::from(slug)), info);
}
agent
.session
.models
.set_current(acp::ModelId::new(std::sync::Arc::from("wide")), None);
agent.apply_context_used(15_000, 1_000_000);
}
/// A follower mirrors a remote switch and installs the effective window
/// the broadcast carries, like the invoking client does.
#[test]
fn model_changed_broadcast_installs_the_reported_context_window() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "narrow", None, Some(262_144));
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total, snap.usage_pct), (15_000, 262_144, 6));
}
/// Same model, same effort, new effective window (the catalog was
/// reloaded and re-applied): the follower must still request a redraw.
#[test]
fn model_changed_same_model_new_window_requests_redraw() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total), (15_000, 2_000_000));
let again = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
assert!(!handle_ext_notification(&again, &mut app));
}
/// A broadcast from a leader that predates the field carries no window;
/// the snapshot is left for the next streaming update to refresh.
#[test]
fn model_changed_broadcast_without_window_keeps_the_snapshot() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "narrow", None, None);
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total), (15_000, 1_000_000));
}
#[test]
fn models_update_without_active_agent_uses_shell_default() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
@@ -232,7 +296,7 @@
let scrollback_before = agent.scrollback.len();
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -278,7 +342,7 @@
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "auto", None);
let notif = model_changed_ext("sess-1", "auto", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -328,7 +392,7 @@
agent.session.model_switch_pending = true;
let scrollback_before = agent.scrollback.len();
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -369,7 +433,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None);
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -400,7 +464,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"));
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None);
assert!(handle_ext_notification(&notif, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
@@ -422,7 +486,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-OTHER", "kigi-4", None);
let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(!changed);
+4 -1
View File
@@ -2112,7 +2112,10 @@ pub enum TaskResult {
agent_id: AgentId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
result: Result<(), SwitchModelError>,
/// Ok carries the effective context window the shell reported
/// (`totalContextTokens` in the response meta; `None` from a shell
/// that predates it).
result: Result<Option<u64>, SwitchModelError>,
/// Forwarded from `Effect::SwitchModel.prev_model_id` for
/// rollback on `IncompatibleAgent`.
prev_model_id: Option<acp::ModelId>,
@@ -733,6 +733,20 @@ impl AgentView {
}
}
}
/// The shell reported the session's effective context window (model
/// switch). The snapshot caches the previous model's window, so the
/// header would show it until the next streaming update. Returns
/// whether the snapshot changed, so the caller can request a redraw.
pub fn apply_context_window(&mut self, total: u64) -> bool {
let Some((used, cached)) = self.context_state.as_ref().map(|c| (c.used, c.total)) else {
return false;
};
if cached == total {
return false;
}
self.apply_context_used(used, total);
true
}
/// Record a key event to the input flight recorder.
///
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
@@ -1262,6 +1276,36 @@ mod resolve_turn_activity_tests {
}
}
#[cfg(test)]
mod apply_context_window_tests {
use super::super::test_agent_view;
fn view() -> super::AgentView {
test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
}
#[test]
fn reported_window_replaces_the_cached_total() {
let mut agent = view();
agent.apply_context_used(15_000, 1_000_000);
assert!(agent.apply_context_window(262_144));
let snap = agent.context_state.as_ref().unwrap();
assert_eq!(
(snap.used, snap.total, snap.usage_pct, snap.free_tokens),
(15_000, 262_144, 6, 262_144 - 15_000)
);
}
#[test]
fn same_window_reports_no_change() {
let mut agent = view();
agent.apply_context_used(15_000, 1_000_000);
assert!(!agent.apply_context_window(1_000_000));
}
#[test]
fn no_snapshot_stays_none() {
let mut agent = view();
assert!(!agent.apply_context_window(262_144));
assert!(agent.context_state.is_none());
}
}
#[cfg(test)]
mod status_window_tests {
use super::super::test_agent_view;
#[test]
@@ -978,13 +978,13 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
agent_id: AgentId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
result: Result<(), SwitchModelError>,
result: Result<Option<u64>, SwitchModelError>,
prev_model_id: Option<acp::ModelId>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.session.model_switch_pending = false;
let mut effects = match result {
Ok(()) => {
Ok(context_window) => {
agent.session.user_model_preference = Some(model_id.clone());
let display_name = agent
.session
@@ -996,6 +996,9 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
let prev_model = agent.session.models.current.clone();
let prev_effort = agent.session.models.reasoning_effort;
agent.session.models.set_current(model_id.clone(), effort);
if let Some(total) = context_window {
agent.apply_context_window(total);
}
let resolved_effort = agent.session.models.reasoning_effort;
let unchanged =
prev_model.as_ref() == Some(&model_id) && prev_effort == resolved_effort;
@@ -1789,7 +1789,7 @@ fn switch_model_holds_prompt_until_complete() {
agent_id: id,
model_id,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -282,7 +282,7 @@ fn model_switch_pending_resets_correctly_across_success_and_failure() {
agent_id: id,
model_id: model_a,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -535,7 +535,7 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -554,6 +554,96 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
));
}
/// The bare `/model <name>` path: `SetDefaultModel` moves `models.current`
/// before the ACP round trip; the completion then installs the effective
/// window the shell reported, so the header repaints at once.
#[test]
fn switch_model_complete_installs_the_reported_context_window() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model = |slug: &str, window: u64| {
let mid = acp::ModelId::new(std::sync::Arc::from(slug));
let info = acp::ModelInfo::new(mid.clone(), slug.to_string()).meta(
serde_json::json!({ "totalContextTokens": window })
.as_object()
.cloned(),
);
(mid, info)
};
let (wide, wide_info) = model("wide", 1_000_000);
let (narrow, narrow_info) = model("narrow", 262_144);
{
let agent = app.agents.get_mut(&id).unwrap();
agent
.session
.models
.available
.insert(wide.clone(), wide_info);
agent
.session
.models
.available
.insert(narrow.clone(), narrow_info);
agent.session.models.set_current(wide.clone(), None);
agent.apply_context_used(15_000, 1_000_000);
}
let effects = dispatch(Action::SetDefaultModel(narrow.clone()), &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { .. }))
);
assert!(app.agents[&id].session.model_switch_pending);
assert_eq!(
app.agents[&id].context_state.as_ref().unwrap().total,
1_000_000
);
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: narrow,
effort: None,
result: Ok(Some(262_144)),
prev_model_id: Some(wide),
}),
&mut app,
);
let agent = app.agents.get_mut(&id).unwrap();
let snap = agent.context_state.as_ref().unwrap();
assert_eq!(
(snap.used, snap.total, snap.usage_pct),
(15_000, 262_144, 6)
);
let area = ratatui::layout::Rect::new(0, 0, 100, 40);
let mut buf = ratatui::buffer::Buffer::empty(area);
agent.draw(
area,
&mut buf,
&crate::actions::ActionRegistry::defaults(),
&mut crate::scrollback::render::ScratchBuffer::new(),
None,
false,
0,
None,
&crate::app::bundle::BundleState::default(),
false,
&mut Vec::new(),
);
let screen: String = (0..area.height)
.map(|y| {
(0..area.width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(screen.contains("15K / 262K"), "header: {screen:?}");
assert!(!screen.contains("1.0M"), "stale window: {screen:?}");
}
#[test]
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
let mut app = test_app_with_agent();
@@ -575,7 +665,7 @@ fn switch_model_complete_skips_message_and_persist_when_unchanged() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -630,7 +720,7 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
model_id: model_id.clone(),
// user typed `/model Blackbox 4.7` with no effort
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -696,7 +786,7 @@ fn switch_to_non_reasoning_model_clears_persisted_effort() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -930,7 +1020,7 @@ fn same_agent_type_switch_no_modal() {
agent_id: id,
model_id: model_b.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -969,7 +1059,7 @@ fn switch_model_pending_lifecycle() {
agent_id: id,
model_id,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -1587,7 +1587,12 @@ pub(crate) fn execute(
.meta(meta);
let result = acp_send(req, &tx)
.await
.map(|_| ())
.map(|resp| {
resp.meta
.as_ref()
.and_then(|m| m.get("totalContextTokens"))
.and_then(serde_json::Value::as_u64)
})
.map_err(|e| {
use kigi_shell::agent::config::ModelSwitchIncompatibleAgentError;
if let Some(typed) = ModelSwitchIncompatibleAgentError::from_acp_error(