Skip to main content

roder_api/
tui_status.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4
5use crate::policy_mode::PolicyMode;
6
7pub type StatusSegmentId = String;
8pub type PaletteSourceId = String;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub enum StatusStyle {
12    Default,
13    Muted,
14    Accent,
15    Warning,
16    Error,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct StatusCell {
21    pub text: String,
22    pub style: StatusStyle,
23    pub tooltip: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ThreadSummary {
28    pub thread_id: String,
29    pub title: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct ThreadUsage {
34    pub input_tokens: u64,
35    pub output_tokens: u64,
36    pub total_cost_usd: Option<f64>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct GitSnapshot {
41    pub branch: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct VcsStatusSnapshot {
46    pub provider_id: String,
47    pub provider_name: String,
48    pub line_of_work: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct McpServerStatus {
53    pub id: String,
54    pub state: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct RunnerSummary {
59    pub destination_id: String,
60    pub provider_id: String,
61    pub state: String,
62}
63
64pub struct StatusContext<'a> {
65    pub thread: &'a ThreadSummary,
66    pub policy_mode: PolicyMode,
67    pub model: Option<&'a str>,
68    pub model_profile: Option<&'a str>,
69    pub model_switch_summary: Option<&'a str>,
70    pub usage: Option<&'a ThreadUsage>,
71    pub git: Option<&'a GitSnapshot>,
72    pub vcs: Option<&'a VcsStatusSnapshot>,
73    pub mcp: &'a [McpServerStatus],
74    pub runner: Option<&'a RunnerSummary>,
75}
76
77pub struct StatusSegment {
78    pub id: StatusSegmentId,
79    pub priority: i32,
80    pub min_width: u16,
81    pub render: Arc<dyn Fn(&StatusContext<'_>) -> StatusCell + Send + Sync>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct PaletteSourceDescriptor {
86    pub id: PaletteSourceId,
87    pub label: String,
88    pub priority: i32,
89}
90
91impl Clone for StatusSegment {
92    fn clone(&self) -> Self {
93        Self {
94            id: self.id.clone(),
95            priority: self.priority,
96            min_width: self.min_width,
97            render: Arc::clone(&self.render),
98        }
99    }
100}
101
102impl StatusSegment {
103    pub fn new(
104        id: impl Into<StatusSegmentId>,
105        priority: i32,
106        min_width: u16,
107        render: impl Fn(&StatusContext<'_>) -> StatusCell + Send + Sync + 'static,
108    ) -> Self {
109        Self {
110            id: id.into(),
111            priority,
112            min_width,
113            render: Arc::new(render),
114        }
115    }
116}
117
118pub fn built_in_status_segments() -> Vec<StatusSegment> {
119    vec![
120        StatusSegment::new("mode", 100, 8, |ctx| StatusCell {
121            text: format!("mode:{}", policy_mode_label(ctx.policy_mode)),
122            style: StatusStyle::Accent,
123            tooltip: Some("Active policy mode".to_string()),
124        }),
125        StatusSegment::new("model", 90, 8, |ctx| StatusCell {
126            text: ctx
127                .model
128                .map(|model| format!("model:{model}"))
129                .unwrap_or_else(|| "model:-".to_string()),
130            style: StatusStyle::Default,
131            tooltip: Some("Active model".to_string()),
132        }),
133        StatusSegment::new("profile", 85, 8, |ctx| StatusCell {
134            text: ctx
135                .model_profile
136                .map(|profile| format!("profile:{profile}"))
137                .unwrap_or_else(|| "profile:-".to_string()),
138            style: if ctx.model_switch_summary.is_some() {
139                StatusStyle::Warning
140            } else {
141                StatusStyle::Muted
142            },
143            tooltip: ctx
144                .model_switch_summary
145                .map(str::to_string)
146                .or_else(|| Some("Active model harness profile".to_string())),
147        }),
148        StatusSegment::new("thread", 80, 8, |ctx| StatusCell {
149            text: format!("thread:{}", short_id(&ctx.thread.thread_id)),
150            style: StatusStyle::Muted,
151            tooltip: ctx.thread.title.clone(),
152        }),
153        StatusSegment::new("branch", 70, 8, |ctx| StatusCell {
154            text: ctx
155                .vcs
156                .and_then(|vcs| vcs.line_of_work.as_deref())
157                .or_else(|| ctx.git.and_then(|git| git.branch.as_deref()))
158                .map(|line| format!("line:{line}"))
159                .unwrap_or_else(|| "line:-".to_string()),
160            style: StatusStyle::Muted,
161            tooltip: ctx
162                .vcs
163                .map(|vcs| format!("{} provider", vcs.provider_name))
164                .or_else(|| Some("Best-effort git branch".to_string())),
165        }),
166        StatusSegment::new("usage", 60, 8, |ctx| StatusCell {
167            text: ctx
168                .usage
169                .map(|usage| format!("tok:{}", usage.input_tokens + usage.output_tokens))
170                .unwrap_or_else(|| "tok:-".to_string()),
171            style: StatusStyle::Muted,
172            tooltip: Some("Thread token usage".to_string()),
173        }),
174        StatusSegment::new("mcp", 50, 6, |ctx| StatusCell {
175            text: format!("mcp:{}", ctx.mcp.len()),
176            style: StatusStyle::Muted,
177            tooltip: Some("Configured MCP servers".to_string()),
178        }),
179        StatusSegment::new("runner", 45, 8, |ctx| {
180            let Some(runner) = ctx.runner else {
181                return StatusCell {
182                    text: "runner:local".to_string(),
183                    style: StatusStyle::Muted,
184                    tooltip: Some("Local filesystem and process execution".to_string()),
185                };
186            };
187            StatusCell {
188                text: format!("runner:{}", runner.destination_id),
189                style: if runner.state == "failed" {
190                    StatusStyle::Error
191                } else {
192                    StatusStyle::Accent
193                },
194                tooltip: Some(format!("{} via {}", runner.state, runner.provider_id)),
195            }
196        }),
197    ]
198}
199
200fn short_id(id: &str) -> &str {
201    id.get(..8).unwrap_or(id)
202}
203
204fn policy_mode_label(mode: PolicyMode) -> &'static str {
205    match mode {
206        PolicyMode::Default => "default",
207        PolicyMode::AcceptAll => "accept_all",
208        PolicyMode::Plan => "plan",
209        PolicyMode::Bypass => "bypass",
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn vcs_status_snapshot_round_trips_provider_and_line_of_work() {
219        let snapshot = VcsStatusSnapshot {
220            provider_id: "git".to_string(),
221            provider_name: "Git".to_string(),
222            line_of_work: Some("main".to_string()),
223        };
224
225        let encoded = serde_json::to_value(&snapshot).expect("serialize vcs status snapshot");
226        let decoded =
227            serde_json::from_value::<VcsStatusSnapshot>(encoded).expect("deserialize snapshot");
228
229        assert_eq!(decoded, snapshot);
230    }
231}