diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-16 02:26:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-16 02:26:14 +0300 |
| commit | 72770305d388fd5ec247493e05a89b686c6d0026 (patch) | |
| tree | 44d95c9c69042ab07e27ace37853e190b6f00ba0 /src | |
| parent | 58c379f1f07adb4113e5f9ee673768b2e4b5be07 (diff) | |
feat: add hook-owned custom agent status labels
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 10 | ||||
| -rw-r--r-- | src/app/actions.rs | 11 | ||||
| -rw-r--r-- | src/app/api.rs | 12 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 9 | ||||
| -rw-r--r-- | src/app/creation.rs | 1 | ||||
| -rw-r--r-- | src/cli.rs | 121 | ||||
| -rw-r--r-- | src/events.rs | 1 | ||||
| -rw-r--r-- | src/pane/state.rs | 42 | ||||
| -rw-r--r-- | src/server/headless.rs | 1 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 4 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 8 | ||||
| -rw-r--r-- | src/workspace/aggregate.rs | 2 |
13 files changed, 215 insertions, 8 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index a3bc674f..f125b156 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -941,6 +941,7 @@ impl ActiveAgentStatusChangedSubscription { workspace_id: pane.workspace_id, agent_status: current_status, agent: pane.agent, + custom_status: pane.custom_status, }), }) } diff --git a/src/api/schema.rs b/src/api/schema.rs index d2bf3ff6..bed8415a 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -211,6 +211,8 @@ pub struct PaneReportAgentParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option<u64>, } @@ -546,6 +548,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, pub revision: u64, } @@ -612,6 +616,8 @@ pub struct PaneAgentStatusChangedEvent { pub agent_status: AgentStatus, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -676,6 +682,8 @@ pub enum EventData { pane_id: String, workspace_id: String, agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_status: Option<String>, }, } @@ -734,6 +742,7 @@ mod tests { agent: "pi".into(), state: PaneAgentState::Working, message: Some("thinking".into()), + custom_status: Some("indexing".into()), seq: Some(42), }), }; @@ -1026,6 +1035,7 @@ mod tests { label: None, agent: None, agent_status: AgentStatus::Unknown, + custom_status: None, revision: 0, }, }, diff --git a/src/app/actions.rs b/src/app/actions.rs index 21e17bf0..870044a8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -683,10 +683,18 @@ impl AppState { agent_label, state, message, + custom_status, seq, } => self .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent_label, state, message, seq) + pane.set_hook_authority_with_custom_status( + source, + agent_label, + state, + message, + custom_status, + seq, + ) }) .into_iter() .collect(), @@ -1365,6 +1373,7 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, + custom_status: None, seq: None, }); diff --git a/src/app/api.rs b/src/app/api.rs index 1fb11118..8c929e9a 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -3,8 +3,8 @@ use std::time::{Duration, Instant}; use super::{ api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_reported_agent_label, - pane_agent_status, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, + normalize_reported_agent_label, pane_agent_status, }, App, Mode, OverlayPaneState, ToastKind, }; @@ -193,12 +193,19 @@ impl App { .and_then(|ws| ws.pane_state(update.pane_id)) .map(|pane| pane_agent_status(pane.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, true)); + let custom_status = self + .state + .workspaces + .get(update.ws_idx) + .and_then(|ws| ws.pane_state(update.pane_id)) + .and_then(|pane| pane.effective_custom_status().map(str::to_string)); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentStatusChanged, data: crate::api::schema::EventData::PaneAgentStatusChanged { pane_id, workspace_id, agent_status, + custom_status, }, }); } @@ -1067,6 +1074,7 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, + custom_status: normalize_custom_status(params.custom_status), seq: params.seq, }); SuccessResponse { diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 011e5ccb..56907ece 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -90,3 +90,12 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { } Some(trimmed.to_string()) } + +pub(super) fn normalize_custom_status(status: Option<String>) -> Option<String> { + let trimmed = status?.trim().to_string(); + let mut normalized = String::new(); + for ch in trimmed.chars().filter(|ch| !ch.is_control()).take(32) { + normalized.push(ch); + } + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) +} diff --git a/src/app/creation.rs b/src/app/creation.rs index 51dac1e8..a2758d7a 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -248,6 +248,7 @@ impl App { label: pane.manual_label.clone(), agent: pane.effective_agent_label().map(str::to_string), agent_status: pane_agent_status(pane.state, pane.seen), + custom_status: pane.effective_custom_status().map(str::to_string), revision: 0, }) } @@ -6,11 +6,12 @@ use serde::Serialize; use crate::api; use crate::api::schema::{ - AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneListParams, - PaneReadParams, PaneRenameParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, - PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, - Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, - TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, + AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState, + PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, + PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, + PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, + TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, + WorkspaceTarget, }; pub enum CommandOutcome { @@ -279,6 +280,7 @@ fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), + "report-agent" => pane_report_agent(&args[1..]), "run" => pane_run(&args[1..]), "help" | "--help" | "-h" => { print_pane_help(); @@ -1009,6 +1011,102 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> { })) } +fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut source = None; + let mut agent = None; + let mut state = None; + let mut message = None; + let mut custom_status = None; + let mut seq = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--agent" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent"); + return Ok(2); + }; + agent = Some(value.clone()); + index += 2; + } + "--state" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --state"); + return Ok(2); + }; + state = Some(parse_pane_agent_state(value)?); + index += 2; + } + "--message" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --message"); + return Ok(2); + }; + message = Some(value.clone()); + index += 2; + } + "--custom-status" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --custom-status"); + return Ok(2); + }; + custom_status = Some(value.clone()); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(parse_u64_flag("--seq", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(source) = source else { + eprintln!("missing required --source"); + return Ok(2); + }; + let Some(agent) = agent else { + eprintln!("missing required --agent"); + return Ok(2); + }; + let Some(state) = state else { + eprintln!("missing required --state"); + return Ok(2); + }; + + send_ok_request(Method::PaneReportAgent(PaneReportAgentParams { + pane_id, + source, + agent, + state, + message, + custom_status, + seq, + })) +} + fn integration_status(args: &[String]) -> std::io::Result<i32> { let outdated_only = match args { [] => false, @@ -1408,6 +1506,18 @@ fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> { } } +fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> { + match value { + "idle" => Ok(PaneAgentState::Idle), + "working" => Ok(PaneAgentState::Working), + "blocked" => Ok(PaneAgentState::Blocked), + "unknown" => Ok(PaneAgentState::Unknown), + _ => Err(std::io::Error::other(format!( + "invalid pane agent state: {value} (expected idle, working, blocked, or unknown)" + ))), + } +} + fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> { value .parse::<u32>() @@ -1530,6 +1640,7 @@ fn print_pane_help() { eprintln!(" herdr pane close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); eprintln!(" herdr pane run <pane_id> <command>"); } diff --git a/src/events.rs b/src/events.rs index 8d1ff47a..6016fe1b 100644 --- a/src/events.rs +++ b/src/events.rs @@ -25,6 +25,7 @@ pub enum AppEvent { agent_label: String, state: AgentState, message: Option<String>, + custom_status: Option<String>, seq: Option<u64>, }, /// Hook authority was explicitly cleared for a pane. diff --git a/src/pane/state.rs b/src/pane/state.rs index f9b7a149..2a2f789e 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -10,6 +10,7 @@ pub struct HookAuthority { pub agent_label: String, pub state: AgentState, pub message: Option<String>, + pub custom_status: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -71,6 +72,7 @@ impl PaneState { self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } + #[cfg(test)] pub fn set_hook_authority( &mut self, source: String, @@ -79,6 +81,18 @@ impl PaneState { message: Option<String>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { + self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) + } + + pub fn set_hook_authority_with_custom_status( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + custom_status: Option<String>, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { if !self.accept_hook_report(&source, seq) { return None; } @@ -91,6 +105,7 @@ impl PaneState { agent_label, state, message, + custom_status, }); self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } @@ -186,6 +201,12 @@ impl PaneState { self.detected_agent } + pub fn effective_custom_status(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .and_then(|authority| authority.custom_status.as_deref()) + } + pub fn set_manual_label(&mut self, label: String) { let label = label.trim().to_string(); self.manual_label = (!label.is_empty()).then_some(label); @@ -459,6 +480,27 @@ mod tests { } #[test] + fn custom_status_is_hook_authority_metadata() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Claude), AgentState::Idle); + pane.set_hook_authority_with_custom_status( + "herdr:claude".into(), + "claude".into(), + AgentState::Idle, + None, + Some("scheduled".into()), + None, + ); + + assert_eq!(pane.state, AgentState::Idle); + assert_eq!(pane.effective_custom_status(), Some("scheduled")); + + pane.clear_hook_authority(Some("herdr:claude"), None); + + assert_eq!(pane.effective_custom_status(), None); + } + + #[test] fn stale_hook_report_sequence_is_ignored_for_same_source() { let mut pane = PaneState::new(); pane.set_hook_authority( diff --git a/src/server/headless.rs b/src/server/headless.rs index 29c8de6e..779bb522 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3069,6 +3069,7 @@ mod tests { agent: "pi".into(), state: api::schema::PaneAgentState::Idle, message: None, + custom_status: None, seq: Some(19), }), }, diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 9a09f3e0..9e8c57e9 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -599,6 +599,9 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { if let Some(agent_label) = entry.agent_label.as_deref() { parts.push(agent_label.to_string()); } + if let Some(custom_status) = entry.custom_status.as_deref() { + parts.push(custom_status.to_string()); + } format!(" {}", parts.join(" · ")) } @@ -909,6 +912,7 @@ mod tests { agent_label: agent_label.map(str::to_string), state: AgentState::Idle, seen: true, + custom_status: None, } } diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index f10c4241..29f3525d 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -24,6 +24,7 @@ pub(crate) struct AgentPanelEntry { pub agent_label: Option<String>, pub state: AgentState, pub seen: bool, + pub custom_status: Option<String>, } fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { @@ -126,6 +127,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { agent_label: None, state: detail.state, seen: detail.seen, + custom_status: detail.custom_status, }) .collect() } @@ -147,6 +149,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { agent_label: Some(detail.agent_label), state: detail.state, seen: detail.seen, + custom_status: detail.custom_status, }) }) .collect(), @@ -836,6 +839,10 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { status_spans.push(Span::styled(" · ", agent_style)); status_spans.push(Span::styled(agent_label, agent_style)); } + if let Some(custom_status) = &detail.custom_status { + status_spans.push(Span::styled(" · ", agent_style)); + status_spans.push(Span::styled(custom_status.clone(), agent_style)); + } frame.render_widget( Paragraph::new(Line::from(status_spans)).style(row_style), Rect::new(body.x, row_y, body.width, 1), @@ -934,6 +941,7 @@ mod tests { agent_label: Some("claude".into()), state: AgentState::Idle, seen: true, + custom_status: None, }; let label = format_agent_panel_primary_label(&entry, 18); diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index bd476667..0507781e 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -14,6 +14,7 @@ pub struct PaneDetail { pub agent: Option<Agent>, pub state: AgentState, pub seen: bool, + pub custom_status: Option<String>, } impl Tab { @@ -39,6 +40,7 @@ impl Tab { agent: pane.effective_known_agent(), state: pane.state, seen: pane.seen, + custom_status: pane.effective_custom_status().map(str::to_string), }) }) .collect() |
