Skip to main content

claude_code_statusline_core/modules/
claude_model.rs

1//! Claude model module for displaying the active AI model
2//!
3//! This module shows the current Claude model being used in the session
4//! with optional symbol and styling.
5
6use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8
9/// Module that displays the current Claude model name
10///
11/// Renders the Claude model information with automatic compaction
12/// for version numbers (e.g., "Opus 4.1" → "Opus4.1") and
13/// customizable symbol prefix.
14///
15/// # Configuration
16///
17/// ```toml
18/// [claude_model]
19/// format = "[$symbol$model]($style)"
20/// style = "bold yellow"
21/// symbol = "<"
22/// disabled = false
23/// ```
24///
25/// # Display Rules
26///
27/// - Compacts spaces before digits (e.g., "Sonnet 3.5" → "Sonnet3.5")
28/// - Only displays when model name is non-empty
29/// - Can be disabled via configuration
30pub struct ClaudeModelModule;
31
32impl ClaudeModelModule {
33    /// Create a new ClaudeModelModule instance
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// Create from Context (kept for compatibility)
39    pub fn from_context(_context: &Context) -> Self {
40        Self::new()
41    }
42}
43
44impl Default for ClaudeModelModule {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl Module for ClaudeModelModule {
51    fn name(&self) -> &str {
52        "claude_model"
53    }
54
55    fn should_display(&self, context: &Context, config: &dyn ModuleConfig) -> bool {
56        // Check if the module is disabled in config
57        if let Some(cfg) = config
58            .as_any()
59            .downcast_ref::<crate::types::config::ClaudeModelConfig>()
60        {
61            if cfg.disabled {
62                return false;
63            }
64        }
65        !context.model_display_name().trim().is_empty()
66    }
67
68    fn render(&self, context: &Context, config: &dyn ModuleConfig) -> String {
69        let model = context.model_display_name();
70
71        // Compact pattern like "Opus 4.1" or "Sonnet 4" -> "Opus4.1" / "Sonnet4"
72        // Rule: remove a single space immediately before a digit.
73        let compacted_model = {
74            let s = model;
75            let mut out = String::with_capacity(s.len());
76            let chars: Vec<char> = s.chars().collect();
77            let len = chars.len();
78            let mut i = 0;
79            while i < len {
80                let c = chars[i];
81                if c == ' ' && i + 1 < len && chars[i + 1].is_ascii_digit() {
82                    // skip this space
83                    i += 1;
84                    continue;
85                }
86                out.push(c);
87                i += 1;
88            }
89            out
90        };
91
92        if let Some(cfg) = config
93            .as_any()
94            .downcast_ref::<crate::types::config::ClaudeModelConfig>()
95        {
96            use std::collections::HashMap;
97            let mut tokens = HashMap::new();
98            tokens.insert("model", compacted_model);
99            tokens.insert("symbol", cfg.symbol.clone());
100            return crate::style::render_with_style_template(cfg.format(), &tokens, cfg.style());
101        }
102
103        compacted_model
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::config::Config;
111    use crate::types::claude::{ClaudeInput, ModelInfo, WorkspaceInfo};
112    use crate::types::context::Context;
113    use rstest::*;
114
115    /// Helper to create context with specific model
116    fn context_with_model(model_name: &str) -> Context {
117        let input = ClaudeInput {
118            hook_event_name: None,
119            session_id: "test-session".to_string(),
120            transcript_path: None,
121            cwd: "/test/dir".to_string(),
122            model: ModelInfo {
123                id: format!("claude-{}", model_name.to_lowercase()),
124                display_name: model_name.to_string(),
125            },
126            workspace: Some(WorkspaceInfo {
127                current_dir: "/test/dir".to_string(),
128                project_dir: Some("/test".to_string()),
129            }),
130            version: Some("1.0.0".to_string()),
131            output_style: None,
132        };
133        Context::new(input, Config::default())
134    }
135
136    #[rstest]
137    #[case("Opus")]
138    #[case("Sonnet")]
139    #[case("Haiku")]
140    #[case("Claude-3.5")]
141    fn test_model_rendering(#[case] model_name: &str) {
142        let module = ClaudeModelModule::new();
143        let context = context_with_model(model_name);
144
145        assert_eq!(module.name(), "claude_model");
146        assert!(module.should_display(&context, &context.config.claude_model));
147        let rendered = module.render(&context, &context.config.claude_model);
148        assert!(rendered.contains(model_name));
149        // Default config applies ANSI style
150        assert!(rendered.starts_with("\u{1b}[") && rendered.ends_with("\u{1b}[0m"));
151    }
152
153    #[rstest]
154    #[case("", false)]
155    #[case("   ", false)]
156    #[case("\t\n", false)]
157    #[case("Opus", true)]
158    fn test_should_display_with_different_model_names(
159        #[case] model_name: &str,
160        #[case] should_display: bool,
161    ) {
162        let module = ClaudeModelModule::new();
163        let context = context_with_model(model_name);
164
165        assert_eq!(
166            module.should_display(&context, &context.config.claude_model),
167            should_display
168        );
169    }
170
171    #[rstest]
172    fn test_module_metadata() {
173        let module = ClaudeModelModule::new();
174        assert_eq!(module.name(), "claude_model");
175    }
176
177    #[rstest]
178    fn test_from_context_constructor() {
179        let context = context_with_model("Opus");
180        let module = ClaudeModelModule::from_context(&context);
181        assert_eq!(module.name(), "claude_model");
182    }
183
184    #[rstest]
185    fn compacts_space_before_digits() {
186        let module = ClaudeModelModule::new();
187        let context = context_with_model("Sonnet 4");
188        let rendered = module.render(&context, &context.config.claude_model);
189        // strip ANSI for assertion
190        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
191        assert!(plain.contains("Sonnet4"));
192        assert!(!plain.contains("Sonnet 4"));
193    }
194}