1pub mod agent;
2pub mod git_stat;
3
4use std::path::Path;
5use std::path::PathBuf;
6
7use crate::agent::Agent;
8use crate::agent::AgentEventKind;
9use crate::agent::AgentEventPayload;
10use crate::git_stat::GitStat;
11
12pub const EMPTY_FIELD: &str = "--";
13
14#[derive(Debug, PartialEq)]
15pub enum ParseError {
16 Missing(&'static str),
17 Invalid { field: &'static str, value: String },
18}
19
20impl ParseError {
21 pub fn invalid(field: &'static str, value: impl Into<String>) -> Self {
22 Self::Invalid {
23 field,
24 value: value.into(),
25 }
26 }
27}
28
29impl std::fmt::Display for ParseError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 ParseError::Missing(field) => write!(f, "missing {field}"),
33 ParseError::Invalid { field, value } => write!(f, "invalid {field}: {value}"),
34 }
35 }
36}
37
38impl std::error::Error for ParseError {}
39
40pub fn short_path(path: &Path, home: &Path) -> String {
43 if home != Path::new("/") {
44 if path == home {
45 return "~".into();
46 }
47 if let Ok(rel) = path.strip_prefix(home) {
48 let names = path_dir_names(rel);
49 return if names.is_empty() {
50 "~".into()
51 } else {
52 format!("~/{}", abbrev_path_dirs(&names))
53 };
54 }
55 }
56
57 let names = path_dir_names(path);
58 if names.is_empty() {
59 "/".into()
60 } else {
61 format!("/{}", abbrev_path_dirs(&names))
62 }
63}
64
65fn path_dir_names(path: &Path) -> Vec<String> {
66 path.components()
67 .filter_map(|component| match component {
68 std::path::Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
69 std::path::Component::Prefix(_)
70 | std::path::Component::RootDir
71 | std::path::Component::CurDir
72 | std::path::Component::ParentDir => None,
73 })
74 .collect()
75}
76
77fn abbrev_path_dirs(names: &[String]) -> String {
78 match names.len() {
79 0 => String::new(),
80 1 => names.first().cloned().unwrap_or_default(),
81 total => {
82 let mut out = String::new();
83 for (idx, name) in names.iter().enumerate() {
84 if idx > 0 {
85 out.push('/');
86 }
87 let is_last = idx == total.saturating_sub(1);
88 if is_last {
89 out.push_str(name);
90 } else {
91 out.push(name.chars().next().unwrap_or('ยท'));
92 }
93 }
94 out
95 }
96 }
97}
98
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub enum Cmd {
101 #[default]
102 None,
103 Running(String),
104 Agent {
105 agent: Agent,
106 state: AgentState,
107 },
108}
109
110impl Cmd {
111 pub const fn agent(agent: Agent, state: AgentState) -> Self {
112 Self::Agent { agent, state }
113 }
114
115 pub const fn waiting(agent: Agent, seen: bool) -> Self {
116 if seen {
117 Self::agent(agent, AgentState::Acknowledged)
118 } else {
119 Self::agent(agent, AgentState::NeedsAttention)
120 }
121 }
122
123 pub const fn tracked_agent(&self) -> Option<Agent> {
124 match self {
125 Self::Agent { agent, .. } => Some(*agent),
126 Self::None | Self::Running(_) => None,
127 }
128 }
129
130 pub const fn agent_state(&self) -> Option<AgentState> {
131 match self {
132 Self::Agent { state, .. } => Some(*state),
133 Self::None | Self::Running(_) => None,
134 }
135 }
136
137 pub fn agent_name(&self) -> Option<&'static str> {
138 self.tracked_agent().map(Agent::name)
139 }
140
141 pub fn running_cmd(&self) -> Option<&str> {
142 match self {
143 Self::Running(s) => Some(s),
144 Self::None | Self::Agent { .. } => None,
145 }
146 }
147
148 pub fn is_busy(&self) -> bool {
149 matches!(
150 self,
151 Self::Agent {
152 state: AgentState::Busy,
153 ..
154 }
155 )
156 }
157
158 pub fn needs_attention(&self) -> bool {
159 matches!(
160 self,
161 Self::Agent {
162 state: AgentState::NeedsAttention,
163 ..
164 }
165 )
166 }
167
168 pub fn acknowledge(&mut self) -> bool {
169 let Self::Agent { state, .. } = self else {
170 return false;
171 };
172 if *state != AgentState::NeedsAttention {
173 return false;
174 }
175 *state = AgentState::Acknowledged;
176 true
177 }
178
179 pub fn from_parts(agent: Option<Agent>, agent_state: Option<AgentState>, command: Option<String>) -> Self {
180 let Some(agent) = agent else {
181 return command.map_or(Self::None, Self::Running);
182 };
183 Self::agent(agent, agent_state.unwrap_or(AgentState::Acknowledged))
184 }
185
186 pub fn into_parts(self) -> (Option<Agent>, Option<AgentState>, Option<String>) {
187 match self {
188 Self::None => (None, None, None),
189 Self::Running(cmd) => (None, None, Some(cmd)),
190 Self::Agent { agent, state } => (Some(agent), Some(state), None),
191 }
192 }
193}
194
195impl From<&AgentEventPayload> for Cmd {
196 fn from(value: &AgentEventPayload) -> Self {
197 match value.kind {
198 AgentEventKind::Start => Self::agent(value.agent, AgentState::Acknowledged),
199 AgentEventKind::Busy => Self::agent(value.agent, AgentState::Busy),
200 AgentEventKind::Idle => Self::agent(value.agent, AgentState::Acknowledged),
201 AgentEventKind::Exit => Self::None,
202 }
203 }
204}
205
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum AgentState {
208 Busy,
209 NeedsAttention,
210 Acknowledged,
211}
212
213impl AgentState {
214 pub const fn as_str(self) -> &'static str {
215 match self {
216 Self::Busy => "busy",
217 Self::NeedsAttention => "needs_attention",
218 Self::Acknowledged => "acknowledged",
219 }
220 }
221
222 pub fn parse(s: &str) -> Result<Self, ParseError> {
223 match s {
224 "busy" => Ok(Self::Busy),
225 "needs_attention" | "waiting_unseen" => Ok(Self::NeedsAttention),
226 "acknowledged" | "waiting_seen" => Ok(Self::Acknowledged),
227 _ => Err(ParseError::invalid("agent_state", format!("{s:?}"))),
228 }
229 }
230}
231
232#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
233pub struct TabStateEntry {
234 pub tab_id: usize,
235 pub cwd: Option<PathBuf>,
236 pub cmd: Cmd,
237 pub git_stat: GitStat,
238}
239
240impl std::fmt::Display for TabStateEntry {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 let cwd_s = self.cwd.as_ref().map(|p| p.display().to_string());
243 let cmd_s = self.cmd.running_cmd();
244 let agent_state = self.cmd.agent_state().map(AgentState::as_str);
245
246 write!(
247 f,
248 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
249 encode_opt(cwd_s.as_deref()),
250 encode_opt(self.cmd.agent_name()),
251 encode_opt(agent_state),
252 self.git_stat.insertions,
253 self.git_stat.deletions,
254 self.git_stat.new_files,
255 u8::from(self.git_stat.is_worktree),
256 encode_opt(cmd_s),
257 )
258 }
259}
260
261impl std::convert::TryFrom<(usize, &str)> for TabStateEntry {
262 type Error = ParseError;
263
264 fn try_from((tab_id, content): (usize, &str)) -> Result<Self, Self::Error> {
265 let mut l = content.lines();
266 let mut next = |name| l.next().ok_or(ParseError::Missing(name));
267
268 let cwd = decode_opt_path(next("cwd")?);
269 let agent_raw = next("agent")?;
270 let agent = if agent_raw == EMPTY_FIELD {
271 None
272 } else {
273 Some(Agent::from_name(agent_raw)?)
274 };
275 let agent_state = match next("agent_state")? {
276 EMPTY_FIELD => None,
277 "0" => Some(AgentState::Acknowledged),
278 "1" => Some(AgentState::Busy),
279 value => Some(AgentState::parse(value)?),
280 };
281 let insertions = parse_usize(next("ins")?, "ins")?;
282 let deletions = parse_usize(next("del")?, "del")?;
283 let new_files = parse_usize(next("new")?, "new")?;
284 let is_worktree = parse_bool(next("wt")?, "wt")?;
285 let command = decode_opt(next("cmd")?);
286
287 Ok(Self {
288 tab_id,
289 cwd,
290 cmd: Cmd::from_parts(agent, agent_state, command),
291 git_stat: GitStat {
292 insertions,
293 deletions,
294 new_files,
295 is_worktree,
296 },
297 })
298 }
299}
300
301fn encode_opt(val: Option<&str>) -> &str {
302 val.unwrap_or(EMPTY_FIELD)
303}
304
305fn decode_opt(val: &str) -> Option<String> {
306 if val == EMPTY_FIELD { None } else { Some(val.to_owned()) }
307}
308
309fn decode_opt_path(val: &str) -> Option<PathBuf> {
310 if val == EMPTY_FIELD {
311 None
312 } else {
313 Some(PathBuf::from(val))
314 }
315}
316
317fn parse_bool(s: &str, name: &'static str) -> Result<bool, ParseError> {
318 match s {
319 "0" => Ok(false),
320 "1" => Ok(true),
321 _ => Err(ParseError::Invalid {
322 field: name,
323 value: format!("{s:?}"),
324 }),
325 }
326}
327
328fn parse_usize(s: &str, name: &'static str) -> Result<usize, ParseError> {
329 s.parse().map_err(|_| ParseError::Invalid {
330 field: name,
331 value: format!("{s:?}"),
332 })
333}
334
335#[cfg(test)]
336mod tests {
337 use std::convert::TryFrom;
338 use std::path::Path;
339
340 use super::*;
341
342 #[test]
343 fn test_tab_state_entry_serialization_roundtrip_works_as_expected() {
344 let entry = TabStateEntry {
345 tab_id: 1,
346 cwd: Some(PathBuf::from("/tmp")),
347 cmd: Cmd::agent(Agent::Claude, AgentState::NeedsAttention),
348 git_stat: GitStat {
349 insertions: 1,
350 deletions: 2,
351 new_files: 3,
352 is_worktree: true,
353 },
354 };
355
356 let content = entry.to_string();
357 assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content.as_str())));
358 pretty_assertions::assert_eq!(parsed, entry);
359 }
360
361 #[test]
362 fn test_short_path_under_home() {
363 let home = Path::new("/home/user");
364 pretty_assertions::assert_eq!(
365 super::short_path(Path::new("/home/user/src/pkg/myproject"), home),
366 "~/s/p/myproject"
367 );
368 }
369
370 #[test]
371 fn test_short_path_many_dirs() {
372 let home = Path::new("/home/user");
373 pretty_assertions::assert_eq!(
374 super::short_path(Path::new("/home/user/one/two/three/four/five"), home),
375 "~/o/t/t/f/five"
376 );
377 }
378
379 #[test]
380 fn test_short_path_outside_home() {
381 let home = Path::new("/home/user");
382 pretty_assertions::assert_eq!(super::short_path(Path::new("/opt/pkg/foo"), home), "/o/p/foo");
383 }
384
385 #[test]
386 fn test_cmd_acknowledge_needs_attention_transitions_to_acknowledged() {
387 let mut cmd = Cmd::agent(Agent::Codex, AgentState::NeedsAttention);
388
389 assert2::assert!(cmd.needs_attention());
390 assert2::assert!(cmd.acknowledge());
391 pretty_assertions::assert_eq!(cmd, Cmd::agent(Agent::Codex, AgentState::Acknowledged));
392 assert2::assert!(!cmd.needs_attention());
393 assert2::assert!(!cmd.acknowledge());
394 }
395}