Skip to main content

linesmith_core/segments/
model.rs

1//! Model segment: renders the current model's display name.
2//!
3//! Anthropic's `display_name` for context-extended variants follows the
4//! shape `Opus 4.7 (1M context)`. The default `format = "compact"`
5//! strips the trailing word "context" so the segment renders
6//! `Opus 4.7 (1M)` — the qualifier is the meaningful capability tag,
7//! the word "context" is filler. Users who prefer the verbatim wire
8//! value set `format = "full"`.
9//!
10//! The transform is gated on `Tool::ClaudeCode`. The `(X context)`
11//! suffix is Claude Code's documented shape; other tools (Qwen,
12//! Codex CLI, Copilot CLI) may use the same suffix with different
13//! semantics, so their `display_name` renders verbatim regardless of
14//! `format`. Cross-tool compaction can grow into a per-tool
15//! transform table when a real second case appears.
16
17use 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
24/// Between context_window (32) and rate_limit (96): identity matters for
25/// multi-model sessions but isn't time-sensitive like the health metrics.
26const PRIORITY: u8 = 64;
27
28const ID: &str = "model";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub(crate) enum ModelFormat {
32    /// Strip the word "context" from a trailing `(X context)`
33    /// parenthetical: `Opus 4.7 (1M context)` → `Opus 4.7 (1M)`.
34    /// No-op when `display_name` doesn't match the suffix shape.
35    #[default]
36    Compact,
37    /// Render `display_name` exactly as Anthropic sent it.
38    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
95/// Strip the trailing word "context" from a `(X context)` parenthetical.
96/// Returns `None` when `s` doesn't end in `" context)"` or when the
97/// matching opening `" ("` can't be found — the segment then falls
98/// through to verbatim rendering. Avoids pulling in `regex` for one
99/// suffix transform.
100fn 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        // Hypothetical future format like "(beta)" — no `context` word,
165        // not our suffix to strip, render verbatim.
166        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        // Hypothetical "(1M extended context)" → "(1M extended)".
176        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        // The `(X context)` suffix is Claude Code's documented shape;
187        // other tools may use the same suffix with different semantics.
188        // Compact must leave their `display_name` untouched.
189        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        // Pin the namespaced warning prefix users grep for in stderr.
264        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        // `(1M context)` with no leading prose: `strip_suffix` succeeds
284        // but `rfind(" (")` fails because the opening paren has no
285        // space before it. Falls through to verbatim.
286        assert_eq!(shorten_context_label("(1M context)"), None);
287    }
288
289    #[test]
290    fn shorten_context_label_returns_none_for_bare_suffix() {
291        // "context)" without any opening `" ("` at all → no transform.
292        assert_eq!(shorten_context_label("context)"), None);
293    }
294
295    #[test]
296    fn compact_picks_rightmost_paren_pair_with_multiple_parentheticals() {
297        // Hypothetical `Opus 4.7 (preview) (1M context)`: only the
298        // trailing `(... context)` is stripped; the earlier `(preview)`
299        // stays. Pins the `rfind` (right-most-wins) choice against a
300        // future `find` (left-most) refactor that would silently swap
301        // the semantics.
302        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}