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(¤t)).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