aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-16 02:26:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-16 02:26:14 +0300
commit72770305d388fd5ec247493e05a89b686c6d0026 (patch)
tree44d95c9c69042ab07e27ace37853e190b6f00ba0 /src
parent58c379f1f07adb4113e5f9ee673768b2e4b5be07 (diff)
feat: add hook-owned custom agent status labels
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs10
-rw-r--r--src/app/actions.rs11
-rw-r--r--src/app/api.rs12
-rw-r--r--src/app/api_helpers.rs9
-rw-r--r--src/app/creation.rs1
-rw-r--r--src/cli.rs121
-rw-r--r--src/events.rs1
-rw-r--r--src/pane/state.rs42
-rw-r--r--src/server/headless.rs1
-rw-r--r--src/ui/mobile.rs4
-rw-r--r--src/ui/sidebar.rs8
-rw-r--r--src/workspace/aggregate.rs2
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,
})
}
diff --git a/src/cli.rs b/src/cli.rs
index eae17384..6aee92ba 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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()