diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 17:10:28 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 17:10:28 +0300 |
| commit | 3750e0bae21c54da3947d337584f180a21d0bc67 (patch) | |
| tree | 4542d8af76b4fee0c428ca5e9378a06946ef62ca /src/app | |
| parent | 1b980eaa9d4cbc050b6b81525e4b6b72ae853be5 (diff) | |
feat: add pane metadata reporting
refs #36
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 125 | ||||
| -rw-r--r-- | src/app/agents.rs | 4 | ||||
| -rw-r--r-- | src/app/api.rs | 17 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 134 | ||||
| -rw-r--r-- | src/app/creation.rs | 6 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/runtime.rs | 16 |
7 files changed, 287 insertions, 17 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index da9e538a..ab2539bf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -93,10 +93,11 @@ pub struct PaneStateUpdate { pub previous_agent_label: Option<String>, pub previous_known_agent: Option<Agent>, pub previous_state: AgentState, + pub previous_presentation: crate::terminal::EffectivePresentation, pub agent_label: Option<String>, pub known_agent: Option<Agent>, pub state: AgentState, - pub custom_status: Option<String>, + pub presentation: crate::terminal::EffectivePresentation, } // --------------------------------------------------------------------------- @@ -333,23 +334,37 @@ impl AppState { let terminal = self.terminals.get(&pane.attached_terminal_id); let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); let label = terminal - .and_then(|terminal| terminal.manual_label.as_deref()) - .or_else(|| terminal.and_then(|terminal| terminal.agent_name.as_deref())) - .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) - .map(str::to_string) + .and_then(|terminal| terminal.effective_title()) + .or_else(|| { + terminal + .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string)) + }) + .or_else(|| { + terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string)) + }) + .or_else(|| { + terminal + .and_then(|terminal| terminal.effective_agent_label().map(str::to_string)) + }) .or_else(|| { launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) }) .unwrap_or_else(|| format!("pane {pane_number}")); - let agent_label = terminal - .and_then(|terminal| terminal.agent_name.as_deref()) - .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())); + let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent()); + let agent_label = display_agent.as_deref().or_else(|| { + terminal + .and_then(|terminal| terminal.agent_name.as_deref()) + .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) + }); let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status()); let state = terminal .map(|terminal| terminal.state) .unwrap_or(AgentState::Unknown); + let status_label = terminal + .map(|terminal| terminal.effective_presentation().state_labels) + .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); let status = custom_status - .map(str::to_string) + .or(status_label) .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); let meta = match (agent_label, status.as_deref()) { (Some(agent_label), Some(status)) => format!("{agent_label} ยท {status}"), @@ -656,6 +671,60 @@ fn activity_summary_for_panes<'a>( // --------------------------------------------------------------------------- impl AppState { + pub(crate) fn next_agent_metadata_expiry(&self) -> Option<std::time::Instant> { + self.terminals + .values() + .filter_map(|terminal| terminal.next_agent_metadata_expiry()) + .min() + } + + pub(crate) fn expire_agent_metadata_at( + &mut self, + scheduled_deadline: std::time::Instant, + now: std::time::Instant, + ) -> Vec<PaneStateUpdate> { + let pane_terminals: Vec<_> = self + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + ws.pane_state(pane_id) + .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) + }) + }) + }) + .collect(); + pane_terminals + .into_iter() + .filter_map(|(ws_idx, pane_id, terminal_id)| { + let mutation = self + .terminals + .get_mut(&terminal_id)? + .expire_agent_metadata_at(scheduled_deadline, now)?; + let change = mutation.effective_state_change?; + let update = PaneStateUpdate { + pane_id, + ws_idx, + previous_agent_label: change.previous_agent_label.clone(), + previous_known_agent: change.previous_known_agent, + previous_state: change.previous_state, + previous_presentation: change.previous_presentation.clone(), + agent_label: change.agent_label.clone(), + known_agent: change.known_agent, + state: change.state, + presentation: change.presentation.clone(), + }; + self.apply_pane_state_change(ws_idx, pane_id, &change); + Some(update) + }) + .collect() + } + pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(active_ws_idx) = self.active else { return false; @@ -1844,6 +1913,41 @@ impl AppState { }) .into_iter() .collect(), + AppEvent::HookMetadataReported { + pane_id, + source, + agent_label, + applies_to_source, + title, + display_agent, + custom_status, + state_labels, + clear_title, + clear_display_agent, + clear_custom_status, + clear_state_labels, + seq, + ttl, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.set_agent_metadata(crate::terminal::AgentMetadataReport { + source, + agent_label, + applies_to_source, + title, + display_agent, + custom_status, + state_labels, + clear_title, + clear_display_agent, + clear_custom_status, + clear_state_labels, + ttl, + seq, + }) + }) + .into_iter() + .collect(), AppEvent::HookAuthorityCleared { pane_id, source, @@ -1904,10 +2008,11 @@ impl AppState { previous_agent_label: change.previous_agent_label.clone(), previous_known_agent: change.previous_known_agent, previous_state: change.previous_state, + previous_presentation: change.previous_presentation.clone(), agent_label: change.agent_label.clone(), known_agent: change.known_agent, state: change.state, - custom_status: change.custom_status.clone(), + presentation: change.presentation.clone(), }; self.apply_pane_state_change(ws_idx, pane_id, &change); Some(update) diff --git a/src/app/agents.rs b/src/app/agents.rs index b1276a06..b9534ce2 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -408,7 +408,11 @@ impl App { terminal_id: pane.terminal_id, name: terminal.agent_name.clone(), agent: pane.agent, + title: pane.title, + display_agent: pane.display_agent, agent_status: pane.agent_status, + custom_status: pane.custom_status, + state_labels: pane.state_labels, workspace_id: pane.workspace_id, tab_id: pane.tab_id, pane_id: pane.pane_id, diff --git a/src/app/api.rs b/src/app/api.rs index 2488ce40..6319bdb6 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -88,6 +88,7 @@ impl App { for update in &pane_updates { self.emit_pane_state_update(update); } + self.sync_agent_metadata_deadline(); if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { @@ -209,7 +210,7 @@ impl App { } } - fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { + pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; }; @@ -226,7 +227,9 @@ impl App { }); } - if update.previous_state != update.state { + if update.previous_state != update.state + || update.previous_presentation != update.presentation + { let agent_status = self .state .workspaces @@ -234,14 +237,17 @@ impl App { .and_then(|ws| ws.pane_state(update.pane_id)) .map(|pane| pane_agent_status(update.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, true)); - let custom_status = update.custom_status.clone(); + let presentation = update.presentation.clone(); 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, + title: presentation.title, + display_agent: presentation.display_agent, + custom_status: presentation.custom_status, + state_labels: presentation.state_labels, }, }); } @@ -413,6 +419,9 @@ impl App { Method::PaneReportAgent(params) => { return self.handle_pane_report_agent(request.id, params); } + Method::PaneReportMetadata(params) => { + return self.handle_pane_report_metadata(request.id, params); + } Method::PaneClearAgentAuthority(params) => { return self.handle_pane_clear_agent_authority(request.id, params); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 9a31ec34..a81a6080 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -3,8 +3,8 @@ use bytes::Bytes; use crate::api::schema::{ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams, PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, - PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, - PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult, + PaneReportAgentParams, PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult, }; use crate::app::{App, Mode}; @@ -198,6 +198,102 @@ impl App { encode_success(id, ResponseResult::Ok {}) } + pub(super) fn handle_pane_report_metadata( + &mut self, + id: String, + params: PaneReportMetadataParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let agent_label = match params.agent.as_deref() { + Some(agent) => match normalize_reported_agent_label(agent) { + Some(agent_label) => Some(agent_label), + None => return invalid_agent(id), + }, + None => None, + }; + let Some(source) = normalize_optional_text(Some(params.source)) else { + return encode_error(id, "invalid_metadata_request", "missing metadata source"); + }; + let raw_title_set = params.title.is_some(); + let raw_display_agent_set = params.display_agent.is_some(); + let raw_custom_status_set = params.custom_status.is_some(); + let raw_state_labels_set = !params.state_labels.is_empty(); + let ttl = params.ttl_ms.map(std::time::Duration::from_millis); + let title = normalize_presentation_text(params.title); + let display_agent = normalize_presentation_text(params.display_agent); + let custom_status = normalize_custom_status(params.custom_status); + let applies_to_source = match params.applies_to_source { + Some(applies_to_source) => { + let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source)) + else { + return encode_error( + id, + "invalid_metadata_request", + "missing metadata authority source", + ); + }; + Some(applies_to_source) + } + None => None, + }; + let state_labels = match normalize_state_labels(params.state_labels) { + Ok(labels) => labels, + Err(status) => { + return encode_error( + id, + "invalid_state_label", + format!("unknown state label: {status}"), + ); + } + }; + if raw_title_set && params.clear_title + || raw_display_agent_set && params.clear_display_agent + || raw_custom_status_set && params.clear_custom_status + || raw_state_labels_set && params.clear_state_labels + { + return encode_error( + id, + "invalid_metadata_request", + "cannot set and clear the same metadata field", + ); + } + if title.is_none() + && display_agent.is_none() + && custom_status.is_none() + && state_labels.is_empty() + && !params.clear_title + && !params.clear_display_agent + && !params.clear_custom_status + && !params.clear_state_labels + { + return encode_error( + id, + "invalid_metadata_request", + "missing metadata field to set or clear", + ); + } + self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { + pane_id, + source, + agent_label, + applies_to_source, + title, + display_agent, + custom_status, + state_labels, + clear_title: params.clear_title, + clear_display_agent: params.clear_display_agent, + clear_custom_status: params.clear_custom_status, + clear_state_labels: params.clear_state_labels, + seq: params.seq, + ttl, + }); + + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn handle_pane_clear_agent_authority( &mut self, id: String, @@ -353,6 +449,40 @@ impl App { } } +fn normalize_optional_text(value: Option<String>) -> Option<String> { + let value = value?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn normalize_presentation_text(value: Option<String>) -> Option<String> { + let trimmed = value?.trim().to_string(); + let normalized: String = trimmed + .chars() + .filter(|ch| !ch.is_control()) + .take(80) + .collect(); + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) +} + +fn normalize_state_labels( + labels: std::collections::HashMap<String, String>, +) -> Result<std::collections::HashMap<String, String>, String> { + labels + .into_iter() + .map(|(status, label)| { + let status = status.trim().to_ascii_lowercase(); + if !matches!( + status.as_str(), + "idle" | "working" | "blocked" | "done" | "unknown" + ) { + return Err(status); + } + Ok(normalize_presentation_text(Some(label)).map(|label| (status, label))) + }) + .filter_map(Result::transpose) + .collect() +} + fn pane_not_found(id: String, pane_id: &str) -> String { encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) } diff --git a/src/app/creation.rs b/src/app/creation.rs index 5bb14953..a034fdc4 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -274,6 +274,7 @@ impl App { && ws .focused_pane_id() .is_some_and(|focused| focused == pane_id); + let presentation = terminal.effective_presentation(); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, terminal_id: terminal.id.to_string(), @@ -285,8 +286,11 @@ impl App { .map(|cwd| cwd.display().to_string()), label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), + title: presentation.title, + display_agent: presentation.display_agent, agent_status: pane_agent_status(terminal.state, pane.seen), - custom_status: terminal.effective_custom_status().map(str::to_string), + custom_status: presentation.custom_status, + state_labels: presentation.state_labels, revision: terminal.revision, }) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c221be8..cad09a71 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -102,6 +102,7 @@ pub struct App { pub(crate) next_resize_poll: Instant, pub(crate) next_animation_tick: Option<Instant>, pub(crate) next_auto_update_check: Option<Instant>, + pub(crate) agent_metadata_deadline: Option<Instant>, pub(crate) selection_autoscroll_deadline: Option<Instant>, pub(crate) selection_highlight_clear_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, @@ -563,6 +564,7 @@ impl App { next_animation_tick: None, next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), + agent_metadata_deadline: None, session_save_deadline: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 2e1f4d81..64232397 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -206,6 +206,17 @@ impl App { self.save_session_now(); } + if let Some(deadline) = self + .agent_metadata_deadline + .filter(|deadline| now >= *deadline) + { + for update in self.state.expire_agent_metadata_at(deadline, now) { + self.emit_pane_state_update(&update); + } + self.sync_agent_metadata_deadline(); + changed = true; + } + self.sync_animation_timer(now); changed } @@ -232,6 +243,10 @@ impl App { false } + pub(crate) fn sync_agent_metadata_deadline(&mut self) { + self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); + } + pub(crate) fn sync_animation_timer(&mut self, now: Instant) { self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL); } @@ -444,6 +459,7 @@ impl App { self.next_animation_tick, self.git_refresh_deadline(), self.next_auto_update_check, + self.agent_metadata_deadline, self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, |
