1use std::collections::BTreeMap;
18
19use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
20use crate::data_context::DataContext;
21use crate::input::Tool;
22use crate::theme::Role;
23
24const PRIORITY: u8 = 64;
27
28const ID: &str = "model";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub(crate) enum ModelFormat {
32 #[default]
36 Compact,
37 Full,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Default)]
42pub(crate) struct Config {
43 pub(crate) format: ModelFormat,
44}
45
46#[derive(Default)]
47pub struct ModelSegment {
48 cfg: Config,
49}
50
51impl ModelSegment {
52 pub fn from_extras(
53 extras: &BTreeMap<String, toml::Value>,
54 warn: &mut impl FnMut(&str),
55 ) -> Self {
56 let mut cfg = Config::default();
57 if let Some(v) = extras.get("format") {
58 match v.as_str() {
59 Some("compact") => cfg.format = ModelFormat::Compact,
60 Some("full") => cfg.format = ModelFormat::Full,
61 _ => warn(&format!(
62 "segments.{ID}.format: expected \"compact\" or \"full\"; ignoring"
63 )),
64 }
65 }
66 Self { cfg }
67 }
68}
69
70impl Segment for ModelSegment {
71 fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
72 let Some(model) = ctx.status.model.as_ref() else {
73 crate::lsm_debug!("model: status.model absent; hiding");
74 return Ok(None);
75 };
76 let raw = model.display_name.trim();
77 if raw.is_empty() {
78 return Ok(None);
79 }
80 let text = match self.cfg.format {
81 ModelFormat::Full => raw.to_string(),
82 ModelFormat::Compact if matches!(ctx.status.tool, Tool::ClaudeCode) => {
83 shorten_context_label(raw).unwrap_or_else(|| raw.to_string())
84 }
85 ModelFormat::Compact => raw.to_string(),
86 };
87 Ok(Some(RenderedSegment::new(text).with_role(Role::Primary)))
88 }
89
90 fn defaults(&self) -> SegmentDefaults {
91 SegmentDefaults::with_priority(PRIORITY)
92 }
93}
94
95fn shorten_context_label(s: &str) -> Option<String> {
101 let stripped = s.strip_suffix(" context)")?;
102 let open_idx = stripped.rfind(" (")?;
103 let qualifier = &stripped[open_idx + 2..];
104 let prefix = &stripped[..open_idx];
105 Some(format!("{prefix} ({qualifier})"))
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
112 use std::path::PathBuf;
113 use std::sync::Arc;
114
115 fn rc() -> RenderContext {
116 RenderContext::new(80)
117 }
118
119 fn ctx(display_name: &str) -> DataContext {
120 ctx_for_tool(Tool::ClaudeCode, display_name)
121 }
122
123 fn ctx_for_tool(tool: Tool, display_name: &str) -> DataContext {
124 DataContext::new(StatusContext {
125 tool,
126 model: Some(ModelInfo {
127 display_name: display_name.into(),
128 }),
129 workspace: Some(WorkspaceInfo {
130 project_dir: PathBuf::from("/repo"),
131 git_worktree: None,
132 }),
133 context_window: None,
134 cost: None,
135 effort: None,
136 vim: None,
137 output_style: None,
138 agent_name: None,
139 version: None,
140 raw: Arc::new(serde_json::Value::Null),
141 })
142 }
143
144 #[test]
145 fn compact_strips_context_word_from_parenthetical() {
146 let seg = ModelSegment::default();
147 assert_eq!(
148 seg.render(&ctx("Opus 4.7 (1M context)"), &rc()).unwrap(),
149 Some(RenderedSegment::new("Opus 4.7 (1M)").with_role(Role::Primary))
150 );
151 }
152
153 #[test]
154 fn compact_passes_through_when_no_parenthetical() {
155 let seg = ModelSegment::default();
156 assert_eq!(
157 seg.render(&ctx("Sonnet 4.5"), &rc()).unwrap(),
158 Some(RenderedSegment::new("Sonnet 4.5").with_role(Role::Primary))
159 );
160 }
161
162 #[test]
163 fn compact_preserves_parenthetical_not_ending_in_context() {
164 let seg = ModelSegment::default();
167 assert_eq!(
168 seg.render(&ctx("Opus 4.7 (beta)"), &rc()).unwrap(),
169 Some(RenderedSegment::new("Opus 4.7 (beta)").with_role(Role::Primary))
170 );
171 }
172
173 #[test]
174 fn compact_handles_multi_word_qualifier() {
175 let seg = ModelSegment::default();
177 assert_eq!(
178 seg.render(&ctx("Opus 4.7 (1M extended context)"), &rc())
179 .unwrap(),
180 Some(RenderedSegment::new("Opus 4.7 (1M extended)").with_role(Role::Primary))
181 );
182 }
183
184 #[test]
185 fn compact_does_not_mutate_non_claude_code_display_names() {
186 let seg = ModelSegment::default();
190 for tool in [
191 Tool::QwenCode,
192 Tool::CodexCli,
193 Tool::CopilotCli,
194 Tool::Other(std::borrow::Cow::Borrowed("custom-tool")),
195 ] {
196 let dc = ctx_for_tool(tool.clone(), "Foo (beta context)");
197 assert_eq!(
198 seg.render(&dc, &rc()).unwrap(),
199 Some(RenderedSegment::new("Foo (beta context)").with_role(Role::Primary)),
200 "tool {tool:?} should not be compacted"
201 );
202 }
203 }
204
205 #[test]
206 fn full_preserves_anthropics_verbatim_string() {
207 let extras = BTreeMap::from([("format".to_string(), toml::Value::String("full".into()))]);
208 let seg = ModelSegment::from_extras(&extras, &mut |_| {});
209 assert_eq!(
210 seg.render(&ctx("Opus 4.7 (1M context)"), &rc()).unwrap(),
211 Some(RenderedSegment::new("Opus 4.7 (1M context)").with_role(Role::Primary))
212 );
213 }
214
215 #[test]
216 fn hidden_when_display_name_is_empty() {
217 assert_eq!(
218 ModelSegment::default().render(&ctx(""), &rc()).unwrap(),
219 None
220 );
221 }
222
223 #[test]
224 fn hidden_when_display_name_is_whitespace_only() {
225 assert_eq!(
226 ModelSegment::default().render(&ctx(" "), &rc()).unwrap(),
227 None
228 );
229 }
230
231 #[test]
232 fn defaults_use_expected_priority() {
233 assert_eq!(ModelSegment::default().defaults().priority, PRIORITY);
234 }
235
236 #[test]
237 fn from_extras_default_is_compact() {
238 let seg = ModelSegment::from_extras(&BTreeMap::new(), &mut |_| {});
239 assert_eq!(seg.cfg.format, ModelFormat::Compact);
240 }
241
242 #[test]
243 fn from_extras_accepts_compact_value() {
244 let extras =
245 BTreeMap::from([("format".to_string(), toml::Value::String("compact".into()))]);
246 let seg = ModelSegment::from_extras(&extras, &mut |_| {});
247 assert_eq!(seg.cfg.format, ModelFormat::Compact);
248 }
249
250 #[test]
251 fn from_extras_accepts_full_value() {
252 let extras = BTreeMap::from([("format".to_string(), toml::Value::String("full".into()))]);
253 let seg = ModelSegment::from_extras(&extras, &mut |_| {});
254 assert_eq!(seg.cfg.format, ModelFormat::Full);
255 }
256
257 #[test]
258 fn from_extras_warns_on_unknown_format_and_keeps_default() {
259 let extras = BTreeMap::from([("format".to_string(), toml::Value::String("brief".into()))]);
260 let mut warnings = vec![];
261 let seg = ModelSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
262 assert_eq!(seg.cfg.format, ModelFormat::Compact);
263 assert!(warnings.iter().any(|w| w.contains("segments.model.format")));
265 }
266
267 #[test]
268 fn from_extras_warns_on_non_string_format() {
269 let extras = BTreeMap::from([("format".to_string(), toml::Value::Integer(1))]);
270 let mut warnings = vec![];
271 let seg = ModelSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
272 assert_eq!(seg.cfg.format, ModelFormat::Compact);
273 assert!(warnings.iter().any(|w| w.contains("segments.model.format")));
274 }
275
276 #[test]
277 fn shorten_context_label_returns_none_for_no_suffix() {
278 assert_eq!(shorten_context_label("Sonnet 4.5"), None);
279 }
280
281 #[test]
282 fn shorten_context_label_returns_none_when_paren_has_no_leading_space() {
283 assert_eq!(shorten_context_label("(1M context)"), None);
287 }
288
289 #[test]
290 fn shorten_context_label_returns_none_for_bare_suffix() {
291 assert_eq!(shorten_context_label("context)"), None);
293 }
294
295 #[test]
296 fn compact_picks_rightmost_paren_pair_with_multiple_parentheticals() {
297 let seg = ModelSegment::default();
303 assert_eq!(
304 seg.render(&ctx("Opus 4.7 (preview) (1M context)"), &rc())
305 .unwrap(),
306 Some(RenderedSegment::new("Opus 4.7 (preview) (1M)").with_role(Role::Primary))
307 );
308 }
309}