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}