use serde::{Deserialize, Serialize};
macro_rules! id_type {
($name:ident) => {
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub u32);
impl $name {
pub const fn new(value: u32) -> Self {
Self(value)
}
pub const fn raw(self) -> u32 {
self.0
}
}
};
}
id_type!(WorkspaceId);
id_type!(HarnessId);
id_type!(SubagentId);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessKind {
ClaudeCode,
OpenCode,
Pi,
Shell,
}
impl HarnessKind {
pub fn badge(self) -> &'static str {
match self {
HarnessKind::ClaudeCode => "CC",
HarnessKind::OpenCode => "OC",
HarnessKind::Pi => "PI",
HarnessKind::Shell => "SH",
}
}
pub fn label(self) -> &'static str {
match self {
HarnessKind::ClaudeCode => "Claude Code",
HarnessKind::OpenCode => "opencode",
HarnessKind::Pi => "pi",
HarnessKind::Shell => "shell",
}
}
pub fn default_command(self) -> &'static str {
match self {
HarnessKind::ClaudeCode => "claude",
HarnessKind::OpenCode => "opencode",
HarnessKind::Pi => "pi",
HarnessKind::Shell => "$SHELL",
}
}
pub const ALL: [HarnessKind; 4] = [
HarnessKind::ClaudeCode,
HarnessKind::OpenCode,
HarnessKind::Pi,
HarnessKind::Shell,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessStatus {
Working,
Idle,
NeedsInput,
Error,
}
impl HarnessStatus {
pub fn label(self) -> &'static str {
match self {
HarnessStatus::Working => "working",
HarnessStatus::Idle => "idle",
HarnessStatus::NeedsInput => "needs input",
HarnessStatus::Error => "error",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subagent {
pub id: SubagentId,
pub label: String,
pub detail: String,
pub elapsed: String,
pub working: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Harness {
pub id: HarnessId,
pub workspace: WorkspaceId,
pub name: String,
pub kind: HarnessKind,
pub cwd: String,
pub status: HarnessStatus,
#[serde(default)]
pub subagents: Vec<Subagent>,
#[serde(default)]
pub unread: u16,
#[serde(default)]
pub tag: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub pronouns: String,
#[serde(default)]
pub color: Option<usize>,
#[serde(default = "default_true")]
pub announced: bool,
#[serde(default = "default_true")]
pub started: bool,
}
fn default_true() -> bool {
true
}
impl Harness {
pub fn working_subagents(&self) -> usize {
self.subagents.iter().filter(|agent| agent.working).count()
}
pub fn has_live_subagents(&self) -> bool {
self.working_subagents() > 0
}
pub fn subagent_count(&self) -> usize {
self.subagents.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: WorkspaceId,
pub name: String,
pub path: String,
pub accent: usize,
pub expanded: bool,
pub harnesses: Vec<Harness>,
}
impl Workspace {
pub fn harness(&self, id: HarnessId) -> Option<&Harness> {
self.harnesses.iter().find(|harness| harness.id == id)
}
pub fn harness_mut(&mut self, id: HarnessId) -> Option<&mut Harness> {
self.harnesses.iter_mut().find(|harness| harness.id == id)
}
pub fn working(&self) -> usize {
self.harnesses
.iter()
.filter(|harness| harness.status == HarnessStatus::Working)
.count()
}
pub fn unread(&self) -> u16 {
self.harnesses.iter().map(|harness| harness.unread).sum()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mail {
pub to: HarnessId,
pub from: String,
pub text: String,
#[serde(default)]
pub read: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Activity {
pub time: String,
pub name: String,
pub message: String,
pub accent: usize,
#[serde(default)]
pub to: Option<(String, usize)>,
#[serde(default)]
pub harness: Option<HarnessId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChoiceOption {
pub label: String,
pub description: String,
pub image: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub from: HarnessId,
pub title: String,
pub question: String,
pub options: Vec<ChoiceOption>,
}
pub const AGENT_COLORS: [usize; 6] = [0, 1, 3, 4, 5, 6];
pub fn assign_agent_colors(workspaces: &mut [Workspace], mut seed: u64) {
let mut used: Vec<usize> = workspaces
.iter()
.flat_map(|w| &w.harnesses)
.filter_map(|h| h.color)
.collect();
for harness in workspaces.iter_mut().flat_map(|w| &mut w.harnesses) {
if harness.color.is_some() {
continue;
}
let mut free: Vec<usize> = AGENT_COLORS
.into_iter()
.filter(|c| !used.contains(c))
.collect();
if free.is_empty() {
free = AGENT_COLORS.to_vec();
}
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let color = free[(seed >> 33) as usize % free.len()];
harness.color = Some(color);
used.push(color);
}
}
#[cfg(test)]
mod color_tests {
use super::*;
#[test]
fn colors_are_unique_while_free_and_allowed() {
let mut ws = vec![Workspace {
id: WorkspaceId::new(1),
name: String::new(),
path: String::new(),
accent: 0,
expanded: true,
harnesses: (1..=6)
.map(|i| Harness {
id: HarnessId::new(i),
workspace: WorkspaceId::new(1),
name: String::new(),
kind: HarnessKind::Shell,
cwd: String::new(),
status: HarnessStatus::Idle,
subagents: Vec::new(),
unread: 0,
tag: String::new(),
title: String::new(),
pronouns: String::new(),
color: None,
announced: true,
started: true,
})
.collect(),
}];
assign_agent_colors(&mut ws, 42);
let mut got: Vec<_> = ws[0].harnesses.iter().map(|h| h.color.unwrap()).collect();
got.sort();
assert_eq!(got, AGENT_COLORS);
}
}