Skip to main content

termden_core/
model.rs

1use serde::{Deserialize, Serialize};
2
3macro_rules! id_type {
4    ($name:ident) => {
5        #[derive(
6            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
7        )]
8        #[serde(transparent)]
9        pub struct $name(pub u32);
10
11        impl $name {
12            pub const fn new(value: u32) -> Self {
13                Self(value)
14            }
15
16            pub const fn raw(self) -> u32 {
17                self.0
18            }
19        }
20    };
21}
22
23id_type!(WorkspaceId);
24id_type!(HarnessId);
25id_type!(SubagentId);
26
27/// The kind of process a harness runs.
28///
29/// `Shell` is the generic escape hatch: any command can live in a harness, but
30/// the agent adapters are what give us subagent telemetry.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum HarnessKind {
34    ClaudeCode,
35    OpenCode,
36    Pi,
37    Shell,
38}
39
40impl HarnessKind {
41    pub fn badge(self) -> &'static str {
42        match self {
43            HarnessKind::ClaudeCode => "CC",
44            HarnessKind::OpenCode => "OC",
45            HarnessKind::Pi => "PI",
46            HarnessKind::Shell => "SH",
47        }
48    }
49
50    pub fn label(self) -> &'static str {
51        match self {
52            HarnessKind::ClaudeCode => "Claude Code",
53            HarnessKind::OpenCode => "opencode",
54            HarnessKind::Pi => "pi",
55            HarnessKind::Shell => "shell",
56        }
57    }
58
59    pub fn default_command(self) -> &'static str {
60        match self {
61            HarnessKind::ClaudeCode => "claude",
62            HarnessKind::OpenCode => "opencode",
63            HarnessKind::Pi => "pi",
64            HarnessKind::Shell => "$SHELL",
65        }
66    }
67
68    pub const ALL: [HarnessKind; 4] = [
69        HarnessKind::ClaudeCode,
70        HarnessKind::OpenCode,
71        HarnessKind::Pi,
72        HarnessKind::Shell,
73    ];
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum HarnessStatus {
79    /// Producing tokens or running a tool.
80    Working,
81    /// Waiting for input.
82    Idle,
83    /// Blocked on a question / permission prompt.
84    NeedsInput,
85    /// Exited non-zero or hit a provider error.
86    Error,
87}
88
89impl HarnessStatus {
90    pub fn label(self) -> &'static str {
91        match self {
92            HarnessStatus::Working => "working",
93            HarnessStatus::Idle => "idle",
94            HarnessStatus::NeedsInput => "needs input",
95            HarnessStatus::Error => "error",
96        }
97    }
98}
99
100/// A child agent spawned by a harness (Claude Code Task, opencode child
101/// session, pi equivalent).
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Subagent {
104    pub id: SubagentId,
105    pub label: String,
106    pub detail: String,
107    pub elapsed: String,
108    pub working: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Harness {
113    pub id: HarnessId,
114    pub workspace: WorkspaceId,
115    pub name: String,
116    pub kind: HarnessKind,
117    /// Working directory, relative to the workspace root when possible.
118    pub cwd: String,
119    pub status: HarnessStatus,
120    #[serde(default)]
121    pub subagents: Vec<Subagent>,
122    #[serde(default)]
123    pub unread: u16,
124    /// Short role the agent sets for itself ("frontend", "debugger").
125    #[serde(default)]
126    pub tag: String,
127    /// One-line description of what the agent is doing, set by the agent.
128    #[serde(default)]
129    pub title: String,
130    /// Pronouns the agent chose for itself ("she/her", "they/them").
131    #[serde(default)]
132    pub pronouns: String,
133    /// Index into the theme accents; unset until `assign_agent_colors` runs.
134    #[serde(default)]
135    pub color: Option<usize>,
136    /// Whether its arrival is already in the activity feed. Agents start false
137    /// and announce themselves when they pick a name; shells log at once.
138    /// Older state files default to true so nothing is logged twice.
139    #[serde(default = "default_true")]
140    pub announced: bool,
141    /// Whether its first working turn is already in the activity feed.
142    #[serde(default = "default_true")]
143    pub started: bool,
144}
145
146fn default_true() -> bool {
147    true
148}
149
150impl Harness {
151    pub fn working_subagents(&self) -> usize {
152        self.subagents.iter().filter(|agent| agent.working).count()
153    }
154
155    pub fn has_live_subagents(&self) -> bool {
156        self.working_subagents() > 0
157    }
158
159    pub fn subagent_count(&self) -> usize {
160        self.subagents.len()
161    }
162}
163
164/// A workspace is a named view over a folder. Two workspaces may point at the
165/// same folder, and a workspace may point at a subfolder of another project.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Workspace {
168    pub id: WorkspaceId,
169    pub name: String,
170    pub path: String,
171    /// Index into the active theme's accent palette.
172    pub accent: usize,
173    pub expanded: bool,
174    pub harnesses: Vec<Harness>,
175}
176
177impl Workspace {
178    pub fn harness(&self, id: HarnessId) -> Option<&Harness> {
179        self.harnesses.iter().find(|harness| harness.id == id)
180    }
181
182    pub fn harness_mut(&mut self, id: HarnessId) -> Option<&mut Harness> {
183        self.harnesses.iter_mut().find(|harness| harness.id == id)
184    }
185
186    pub fn working(&self) -> usize {
187        self.harnesses
188            .iter()
189            .filter(|harness| harness.status == HarnessStatus::Working)
190            .count()
191    }
192
193    pub fn unread(&self) -> u16 {
194        self.harnesses.iter().map(|harness| harness.unread).sum()
195    }
196}
197
198/// One message in a harness mailbox. Persisted so a reload loses nothing.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Mail {
201    pub to: HarnessId,
202    /// Sender's display name at send time (`user` for the human or termdenctl
203    /// outside a tile).
204    pub from: String,
205    pub text: String,
206    #[serde(default)]
207    pub read: bool,
208}
209
210/// One line of the activity feed. Persisted so a reload keeps the history.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Activity {
213    /// Local wall-clock "HH:MM" when it happened.
214    pub time: String,
215    pub name: String,
216    pub message: String,
217    pub accent: usize,
218    /// Recipient of a mail entry (`name → to`) and their color.
219    #[serde(default)]
220    pub to: Option<(String, usize)>,
221    /// Harness this line is about; the feed reads its current name and color.
222    #[serde(default)]
223    pub harness: Option<HarnessId>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ChoiceOption {
228    pub label: String,
229    pub description: String,
230    pub image: Option<std::path::PathBuf>,
231}
232
233/// A question an agent asked the user; stays pending until answered.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Choice {
236    pub from: HarnessId,
237    pub title: String,
238    pub question: String,
239    pub options: Vec<ChoiceOption>,
240}
241
242/// Accent indices agents may get: the theme accents minus green (termden's own
243/// status) and red (errors).
244pub const AGENT_COLORS: [usize; 6] = [0, 1, 3, 4, 5, 6];
245
246/// Give every harness without a color a random one, preferring colors nobody
247/// else wears.
248pub fn assign_agent_colors(workspaces: &mut [Workspace], mut seed: u64) {
249    let mut used: Vec<usize> = workspaces
250        .iter()
251        .flat_map(|w| &w.harnesses)
252        .filter_map(|h| h.color)
253        .collect();
254    for harness in workspaces.iter_mut().flat_map(|w| &mut w.harnesses) {
255        if harness.color.is_some() {
256            continue;
257        }
258        let mut free: Vec<usize> = AGENT_COLORS
259            .into_iter()
260            .filter(|c| !used.contains(c))
261            .collect();
262        if free.is_empty() {
263            free = AGENT_COLORS.to_vec();
264        }
265        seed = seed
266            .wrapping_mul(6364136223846793005)
267            .wrapping_add(1442695040888963407);
268        let color = free[(seed >> 33) as usize % free.len()];
269        harness.color = Some(color);
270        used.push(color);
271    }
272}
273
274#[cfg(test)]
275mod color_tests {
276    use super::*;
277
278    #[test]
279    fn colors_are_unique_while_free_and_allowed() {
280        let mut ws = vec![Workspace {
281            id: WorkspaceId::new(1),
282            name: String::new(),
283            path: String::new(),
284            accent: 0,
285            expanded: true,
286            harnesses: (1..=6)
287                .map(|i| Harness {
288                    id: HarnessId::new(i),
289                    workspace: WorkspaceId::new(1),
290                    name: String::new(),
291                    kind: HarnessKind::Shell,
292                    cwd: String::new(),
293                    status: HarnessStatus::Idle,
294                    subagents: Vec::new(),
295                    unread: 0,
296                    tag: String::new(),
297                    title: String::new(),
298                    pronouns: String::new(),
299                    color: None,
300                    announced: true,
301                    started: true,
302                })
303                .collect(),
304        }];
305        assign_agent_colors(&mut ws, 42);
306        let mut got: Vec<_> = ws[0].harnesses.iter().map(|h| h.color.unwrap()).collect();
307        got.sort();
308        assert_eq!(got, AGENT_COLORS);
309    }
310}