From 3750e0bae21c54da3947d337584f180a21d0bc67 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 26 May 2026 17:10:28 +0300 Subject: feat: add pane metadata reporting refs #36 --- src/app/api/panes.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) (limited to 'src/app/api') 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) -> Option { + let value = value?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn normalize_presentation_text(value: Option) -> Option { + 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, +) -> Result, 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")) } -- cgit v1.2.3