Add context window token size

Show each model's context window in the pickers, and add a gauge to the TUI title bar so the share of the window a conversation occupies is visible before compaction fires. Cloud tiers advertise their own window rather than the compaction budget. The budget is when siGit Code summarizes history, not how much the model can hold, and deriving the label from it understated the window by an order of magnitude. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 7, 2026 at 06:59 UTC fc87527a83fe750ddd08e326ccf990691b50f1ba
3 files changed +243 -12
src/chat.rs
+127
@@ -627,6 +627,9 @@ mod tui {
627 StreamEnd,
628 /// a complete (non-streamed) assistant reply
629 Response(String),
630 + /// estimated tokens the conversation now occupies; drives the context
631 + /// gauge in the title bar. Sent after compaction and at end of turn.
632 + ContextUsage(usize),
633 Error(String),
634 /// the inference task wants to run a mutating tool and is paused on
635 /// `reply`; the user answers with y (once) / a (session) / n (deny)
@@ -760,6 +763,9 @@ mod tui {
763 model_picker_items: Vec<ModelPickerItem>,
764 current_model_name: String,
765 tool_calling: bool,
766 + /// Estimated tokens the conversation currently occupies, as of the last
767 + /// completed turn. `None` until the first turn finishes.
768 + context_tokens_used: Option<usize>,
769 /// `/thinking` — expand the model's reasoning on rendered messages.
770 /// Display-only: never changes what is sent to the model or saved.
771 show_thinking: bool,
@@ -889,6 +895,7 @@ mod tui {
895 model_picker_items: items,
896 current_model_name,
897 tool_calling,
898 + context_tokens_used: None,
899 show_thinking: false,
900 tools_expanded: false,
901 backend,
@@ -1009,6 +1016,24 @@ mod tui {
1016 THINKING_FRAMES[idx]
1017 }
1018
1019 + /// Context window of the model currently serving inference, falling back
1020 + /// to the compaction budget when the model isn't in the picker list
1021 + /// (e.g. a provider-supplied name we don't ship a config for).
1022 + ///
1023 + /// An env-configured provider names itself after its model id rather
1024 + /// than a catalogue display name, so match either. Getting this wrong
1025 + /// is silent: the gauge just divides by the wrong number.
1026 + fn context_window_tokens(&self) -> u64 {
1027 + self.model_picker_items
1028 + .iter()
1029 + .find(|item| {
1030 + item.display_name == self.current_model_name
1031 + || item.config.model_id == self.current_model_name
1032 + })
1033 + .map(|item| item.context_window_tokens)
1034 + .unwrap_or(crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
1035 + }
1036 +
1037 fn tick(&mut self) {
1038 self.load_tick = self.load_tick.wrapping_add(1);
1039 }
@@ -1278,6 +1303,10 @@ mod tui {
1303 ModelCacheHealth::NotDownloaded => " ↓ download",
1304 };
1305 let current_badge = if current { " ← current" } else { "" };
1306 + let context_badge = format!(
1307 + " {}",
1308 + crate::models::format_context_window(item.context_window_tokens)
1309 + );
1310 let disabled_badge = match item.cache_health {
1311 ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
1312 ModelCacheHealth::Incomplete => " (unselectable)",
@@ -1333,6 +1362,16 @@ mod tui {
1362 Style::default().fg(Color::Green).bg(Color::Black)
1363 },
1364 ),
1365 + Span::styled(
1366 + context_badge,
1367 + if selected {
1368 + Style::default().fg(Color::Black).bg(Color::Green)
1369 + } else if item_active {
1370 + Style::default().fg(Color::Gray).bg(Color::Black)
1371 + } else {
1372 + Style::default().fg(Color::DarkGray).bg(Color::Black)
1373 + },
1374 + ),
1375 Span::styled(health_badge.to_string(), health_style),
1376 Span::styled(
1377 disabled_badge.to_string(),
@@ -2094,6 +2133,8 @@ mod tui {
2133 } else {
2134 " [tools off] "
2135 };
2136 + let window = app.context_window_tokens();
2137 + let used = app.context_tokens_used.unwrap_or(0) as u64;
2138 let line = Line::from(vec![
2139 Span::styled(
2140 model_label,
@@ -2106,10 +2147,51 @@ mod tui {
2147 tool_label,
2148 Style::default().fg(Color::Black).bg(Color::DarkGray),
2149 ),
2150 + Span::styled(
2151 + format!(" {} ", context_gauge_label(used, window)),
2152 + Style::default()
2153 + .fg(Color::Black)
2154 + .bg(context_gauge_color(used, window)),
2155 + ),
2156 ]);
2157 frame.render_widget(Paragraph::new(line), area);
2158 }
2159
2160 + /// `"84% · 168K/200K ctx"` — the share of the active model's context window
2161 + /// the conversation occupies, as of the last completed turn.
2162 + fn context_gauge_label(used: u64, window: u64) -> String {
2163 + if window == 0 {
2164 + return "ctx —".to_string();
2165 + }
2166 + let percent = (used.saturating_mul(100) / window).min(100);
2167 + format!(
2168 + "{percent}% · {}/{}",
2169 + compact_tokens(used),
2170 + crate::models::format_context_window(window)
2171 + )
2172 + }
2173 +
2174 + /// Warn as the window fills: auto-compaction kicks in near the budget, so
2175 + /// the colour is a heads-up that the next turn may be summarized.
2176 + fn context_gauge_color(used: u64, window: u64) -> Color {
2177 + if window == 0 {
2178 + return Color::DarkGray;
2179 + }
2180 + match used.saturating_mul(100) / window {
2181 + 0..=69 => Color::DarkGray,
2182 + 70..=89 => Color::Yellow,
2183 + _ => Color::Red,
2184 + }
2185 + }
2186 +
2187 + fn compact_tokens(tokens: u64) -> String {
2188 + if tokens >= 1_000 {
2189 + format!("{}K", tokens / 1_000)
2190 + } else {
2191 + tokens.to_string()
2192 + }
2193 + }
2194 +
2195 fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2196 const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2197 let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
@@ -2758,6 +2840,7 @@ mod tui {
2840 crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2841 // The saved session must not resurrect what the user just wiped.
2842 crate::session_store::delete(TUI_STORE_SESSION);
2843 + app.context_tokens_used = None;
2844 app.messages.push(ChatMessage::system(format!(
2845 "Cleared {cleared} turn(s). History is empty.",
2846 )));
@@ -2777,6 +2860,7 @@ mod tui {
2860 {
2861 log::warn!("session save after /compact failed: {error}");
2862 }
2863 + app.context_tokens_used = Some(after);
2864 app.messages.push(ChatMessage::system(format!(
2865 "Compacted history: ~{before} → ~{after} tokens (estimated)."
2866 )));
@@ -2790,6 +2874,7 @@ mod tui {
2874 SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
2875 Some(history) if !history.is_empty() => {
2876 let restored = history.len();
2877 + app.context_tokens_used = Some(crate::backend::estimate_tokens(&history));
2878 app.backend.restore_history(history).await;
2879 app.messages.push(ChatMessage::system(format!(
2880 "Restored {restored} message(s) from the saved session. \
@@ -3150,6 +3235,7 @@ mod tui {
3235 let after =
3236 crate::backend::estimate_tokens(&backend.history_snapshot().await);
3237 log::info!("compacted history to ≈{after} tokens");
3238 + let _ = tx.send(InferenceUpdate::ContextUsage(after)).await;
3239 }
3240 Err(error) => log::warn!("history compaction failed: {error}"),
3241 }
@@ -3312,6 +3398,11 @@ mod tui {
3398 // Persist the completed turn so /resume (or a restart) can pick the
3399 // conversation back up.
3400 let snapshot = backend.history_snapshot().await;
3401 + let _ = tx
3402 + .send(InferenceUpdate::ContextUsage(
3403 + crate::backend::estimate_tokens(&snapshot),
3404 + ))
3405 + .await;
3406 if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
3407 log::warn!("session save failed: {error}");
3408 }
@@ -3368,6 +3459,7 @@ mod tui {
3459 match rx.try_recv() {
3460 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3461 engine.clear_history().await;
3462 + app.context_tokens_used = None;
3463 if let Some(tc) = app.pending_tool_calling.take() {
3464 app.tool_calling = tc;
3465 }
@@ -3485,6 +3577,9 @@ mod tui {
3577 app.stop_thinking();
3578 app.messages.push(ChatMessage::assistant(text));
3579 }
3580 + Some(InferenceUpdate::ContextUsage(tokens)) => {
3581 + app.context_tokens_used = Some(tokens);
3582 + }
3583 Some(InferenceUpdate::Error(msg)) => {
3584 app.finalize_stream();
3585 app.stop_thinking();
@@ -3828,6 +3923,38 @@ mod tui {
3923 format!("{bytes} B")
3924 }
3925 }
3926 + #[cfg(test)]
3927 + mod tui_tests {
3928 + use super::{compact_tokens, context_gauge_color, context_gauge_label};
3929 + use ratatui::style::Color;
3930 +
3931 + #[test]
3932 + fn context_gauge_reports_share_of_the_window() {
3933 + assert_eq!(context_gauge_label(168_000, 200_000), "84% · 168K/200K ctx");
3934 + assert_eq!(context_gauge_label(0, 24_000), "0% · 0/24K ctx");
3935 + // Over-full history (pre-compaction) clamps rather than reading 130%.
3936 + assert_eq!(context_gauge_label(31_000, 24_000), "100% · 31K/24K ctx");
3937 + }
3938 +
3939 + #[test]
3940 + fn context_gauge_handles_an_unknown_window() {
3941 + assert_eq!(context_gauge_label(1_000, 0), "ctx —");
3942 + assert_eq!(context_gauge_color(1_000, 0), Color::DarkGray);
3943 + }
3944 +
3945 + #[test]
3946 + fn context_gauge_warms_up_as_the_window_fills() {
3947 + assert_eq!(context_gauge_color(1_000, 24_000), Color::DarkGray);
3948 + assert_eq!(context_gauge_color(18_000, 24_000), Color::Yellow);
3949 + assert_eq!(context_gauge_color(23_000, 24_000), Color::Red);
3950 + }
3951 +
3952 + #[test]
3953 + fn compact_tokens_abbreviates_thousands_only() {
3954 + assert_eq!(compact_tokens(999), "999");
3955 + assert_eq!(compact_tokens(1_500), "1K");
3956 + }
3957 + }
3958 } // end #[cfg(unix)] mod tui
3959
3960 // re-export so callers write `chat::run_with(...)` on all platforms
src/main.rs
+66 -12
@@ -2566,17 +2566,21 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
2566 if item.tool_calling {
2567 desc_parts.push("tool calling".to_string());
2568 }
2569 + desc_parts.push(format!(
2570 + "{} context",
2571 + models::format_context_window_short(item.context_window_tokens)
2572 + ));
2573 desc_parts.push(item.description.clone());
2574 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2575 desc_parts.push("download on select".to_string());
2576 }
2577 // ASCII-only for the same reason as the name (see `ascii_safe`).
2578 let description = ascii_safe(&desc_parts.join(" - "));
2575 - // Keep badges ASCII: Zed truncates the picker label at a fixed byte
2576 - // offset and panics if the cut splits a multi-byte char. See
2577 - // `ascii_safe` below.
2579 + // Keep labels short for the Zed bottom bar. Source/routing detail
2580 + // belongs in descriptions; the selected value is rendered without
2581 + // the config-option title, so repeated badges become visual noise.
2582 let source_badge = if item.cloud_tier.is_some() {
2579 - " [siGit Code Cloud]"
2583 + ""
2584 } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2585 " [Onde]"
2586 } else {
@@ -2586,15 +2590,17 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
2590 _ => "",
2591 }
2592 };
2589 - // For cloud tiers use just the tier title (e.g. "Balanced") so the
2590 - // label reads "Balanced [siGit Code Cloud]" instead of repeating the
2591 - // brand. The display name can carry non-ASCII (the cloud tier label
2592 - // is "siGit Code Cloud · Balanced"), so sanitize the whole label.
2593 + // For cloud tiers use just the tier title (e.g. "Balanced"). The
2594 + // display name can carry non-ASCII (the cloud tier label is
2595 + // "siGit Code Cloud · Balanced"), so sanitize the whole label.
2596 let base_name = match &item.cloud_tier {
2597 Some(tier) => crate::provider::tier_title(tier),
2598 None => item.display_name.clone(),
2599 };
2597 - let name = ascii_safe(&format!("{base_name}{source_badge}"));
2600 + let name = ascii_safe(&format!(
2601 + "{base_name} - {}{source_badge}",
2602 + models::format_context_window(item.context_window_tokens)
2603 + ));
2604 SessionConfigSelectOption::new(
2605 SessionConfigValueId::new(item.config.model_id.as_str()),
2606 name,
@@ -2614,18 +2620,18 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
2620 let local_options = vec![
2621 SessionConfigSelectOption::new(
2622 SessionConfigValueId::new(LOCAL_INFERENCE_ON),
2617 - "On (on-device)".to_string(),
2623 + "Local".to_string(),
2624 )
2625 .description("Run inference on-device; on-device models are highlighted".to_string()),
2626 SessionConfigSelectOption::new(
2627 SessionConfigValueId::new(LOCAL_INFERENCE_OFF),
2622 - "Off (siGit Code Cloud)".to_string(),
2628 + "Cloud".to_string(),
2629 )
2630 .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
2631 ];
2632 let local_option = SessionConfigOption::select(
2633 LOCAL_INFERENCE_CONFIG_ID,
2628 - "Local Inference",
2634 + "Inference",
2635 local_current,
2636 local_options,
2637 )
@@ -3986,4 +3992,52 @@ mod tests {
3992 assert!(safe.is_char_boundary(i));
3993 }
3994 }
3995 +
3996 + #[test]
3997 + fn acp_config_option_labels_show_context_window() {
3998 + let current = GgufModelConfig {
3999 + model_id: "sigit-cloud:oke".to_string(),
4000 + files: Vec::new(),
4001 + tok_model_id: None,
4002 + display_name: provider::cloud_tier_label("oke"),
4003 + approx_memory: "Cloud".to_string(),
4004 + chat_template: None,
4005 + };
4006 + let options = serde_json::to_value(build_model_config_options(&current)).unwrap();
4007 + let all_options = options.as_array().expect("config options");
4008 +
4009 + let model = all_options
4010 + .iter()
4011 + .find(|option| option["id"] == MODEL_CONFIG_ID)
4012 + .expect("model config option");
4013 + let model_names: Vec<&str> = model["options"]
4014 + .as_array()
4015 + .expect("model select options")
4016 + .iter()
4017 + .filter_map(|option| option["name"].as_str())
4018 + .collect();
4019 + assert!(model_names.contains(&"Oke - 200K ctx"));
4020 + assert!(
4021 + !model_names
4022 + .iter()
4023 + .any(|name| name.contains("siGit Code Cloud")),
4024 + "cloud branding belongs in descriptions, not selected-value labels: {model_names:?}"
4025 + );
4026 + assert!(
4027 + model_names.iter().all(|name| name.contains(" ctx")),
4028 + "every model option should expose its context window: {model_names:?}"
4029 + );
4030 +
4031 + let inference = all_options
4032 + .iter()
4033 + .find(|option| option["id"] == LOCAL_INFERENCE_CONFIG_ID)
4034 + .expect("inference config option");
4035 + let inference_names: Vec<&str> = inference["options"]
4036 + .as_array()
4037 + .expect("inference select options")
4038 + .iter()
4039 + .filter_map(|option| option["name"].as_str())
4040 + .collect();
4041 + assert_eq!(inference_names, ["Local", "Cloud"]);
4042 + }
4043 }
src/models.rs
+50
@@ -53,6 +53,7 @@ pub(crate) struct ModelPickerItem {
53 pub(crate) description: String,
54 pub(crate) tool_calling: bool,
55 pub(crate) max_tokens: u64,
56 + pub(crate) context_window_tokens: u64,
57 pub(crate) config: GgufModelConfig,
58 pub(crate) source_label: String,
59
@@ -97,6 +98,48 @@ fn max_tokens_for(model_id: &str) -> u64 {
98 if is_tool_calling(model_id) { 4096 } else { 512 }
99 }
100
101 +/// Usable context window shown in editor model pickers.
102 +///
103 +/// This is intentionally separate from `max_tokens`, which is the generation
104 +/// cap. Qwen GGUFs are used at their default 32K window; Qwen2.5 can be stretched
105 +/// further with YaRN in some deployments, but siGit Code does not configure that
106 +/// here.
107 +fn context_window_tokens_for(model_id: &str) -> u64 {
108 + if model_id == "TheBloke/deepseek-coder-6.7B-instruct-GGUF" {
109 + 16_000
110 + } else {
111 + 32_768
112 + }
113 +}
114 +
115 +/// Context window advertised for siGit Code Cloud tiers.
116 +///
117 +/// Deliberately a value of its own rather than the compaction budget: the
118 +/// budget is when siGit Code summarizes history, not how much the model can
119 +/// hold.
120 +/// Deriving the label from it made a user-facing number move whenever that
121 +/// tuning constant changed, and understated the window by an order of
122 +/// magnitude.
123 +pub(crate) const CLOUD_CONTEXT_WINDOW_TOKENS: u64 = 200_000;
124 +
125 +/// Render a context window as a short badge, e.g. `24K ctx`.
126 +///
127 +/// Shared by the TUI `/models` picker and the ACP session-config labels so a
128 +/// model reads the same in both editors.
129 +pub(crate) fn format_context_window(tokens: u64) -> String {
130 + format!("{} ctx", format_context_window_short(tokens))
131 +}
132 +
133 +/// The same magnitude without the `ctx` suffix, for places that supply their
134 +/// own wording (e.g. "200K context").
135 +pub(crate) fn format_context_window_short(tokens: u64) -> String {
136 + if tokens >= 1_000 {
137 + format!("{}K", tokens / 1_000)
138 + } else {
139 + tokens.to_string()
140 + }
141 +}
142 +
143 // ── Builder ───────────────────────────────────────────────────────────────────
144
145 /// collect every model the picker should show: local cache, remote available, fallback.
@@ -125,12 +168,14 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
168
169 let tool_calling = is_tool_calling(info.id);
170 let max_tokens = max_tokens_for(info.id);
171 + let context_window_tokens = context_window_tokens_for(info.id);
172
173 items.push(ModelPickerItem {
174 display_name: config.display_name.clone(),
175 description: config.approx_memory.clone(),
176 tool_calling,
177 max_tokens,
178 + context_window_tokens,
179 config,
180 source_label: "Onde".to_string(),
181
@@ -145,12 +190,14 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
190 let config = GgufModelConfig::platform_default();
191 let tool_calling = is_tool_calling(&config.model_id);
192 let max_tokens = max_tokens_for(&config.model_id);
193 + let context_window_tokens = context_window_tokens_for(&config.model_id);
194
195 items.push(ModelPickerItem {
196 display_name: config.display_name.clone(),
197 description: config.approx_memory.clone(),
198 tool_calling,
199 max_tokens,
200 + context_window_tokens,
201 config,
202 source_label: "Platform default".to_string(),
203
@@ -178,6 +225,7 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
225 description: "siGit Code Cloud".to_string(),
226 tool_calling: true,
227 max_tokens: 4096,
228 + context_window_tokens: CLOUD_CONTEXT_WINDOW_TOKENS,
229 config,
230 source_label: "siGit Code Cloud".to_string(),
231 source: ModelSource::Cloud,
@@ -224,12 +272,14 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
272
273 let tool_calling = is_tool_calling(&model.model_id);
274 let max_tokens = max_tokens_for(&model.model_id);
275 + let context_window_tokens = context_window_tokens_for(&model.model_id);
276
277 Some(ModelPickerItem {
278 display_name: config.display_name.clone(),
279 description: config.approx_memory.clone(),
280 tool_calling,
281 max_tokens,
282 + context_window_tokens,
283 config,
284 source_label,
285