diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 89 | ||||
| -rw-r--r-- | src/app/agents.rs | 4 | ||||
| -rw-r--r-- | src/app/api.rs | 25 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 271 | ||||
| -rw-r--r-- | src/app/api/plugins/context.rs | 3 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 157 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 119 | ||||
| -rw-r--r-- | src/app/creation.rs | 5 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 66 | ||||
| -rw-r--r-- | src/app/mod.rs | 65 | ||||
| -rw-r--r-- | src/app/runtime.rs | 43 | ||||
| -rw-r--r-- | src/app/state.rs | 4 | ||||
| -rw-r--r-- | src/app/terminal_titles.rs | 148 |
14 files changed, 874 insertions, 126 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 5c263e7e..7cf43e79 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -537,15 +537,13 @@ impl AppState { .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 - .or(status_label) + let status = 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}"), @@ -893,6 +891,16 @@ impl AppState { self.terminals .values() .filter_map(|terminal| terminal.next_agent_metadata_expiry()) + .chain( + self.terminals + .values() + .filter_map(|terminal| terminal.metadata_tokens.next_expiry()), + ) + .chain( + self.workspaces + .iter() + .filter_map(|workspace| workspace.metadata_tokens.next_expiry()), + ) .min() } @@ -946,6 +954,48 @@ impl AppState { .collect() } + pub(crate) fn expire_metadata_tokens( + &mut self, + now: std::time::Instant, + ) -> (Vec<(usize, PaneId)>, Vec<usize>) { + let pane_terminals = self + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + workspace.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + workspace + .pane_state(pane_id) + .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) + }) + }) + }) + .collect::<Vec<_>>(); + let changed_panes = pane_terminals + .into_iter() + .filter_map(|(ws_idx, pane_id, terminal_id)| { + let terminal = self.terminals.get_mut(&terminal_id)?; + terminal.metadata_tokens.expire_at(now).then(|| { + terminal.revision = terminal.revision.saturating_add(1); + (ws_idx, pane_id) + }) + }) + .collect(); + let changed_workspaces = self + .workspaces + .iter_mut() + .enumerate() + .filter_map(|(ws_idx, workspace)| { + workspace.metadata_tokens.expire_at(now).then_some(ws_idx) + }) + .collect(); + (changed_panes, changed_workspaces) + } + 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; @@ -1341,21 +1391,12 @@ impl AppState { self.view.sidebar_rect, self.sidebar_section_split, ); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let visible = metrics.viewport_rows; - if visible == 0 { - return; - } - - if idx < self.agent_panel_scroll { - self.agent_panel_scroll = idx; - } else if idx >= self.agent_panel_scroll.saturating_add(visible) { - self.agent_panel_scroll = idx.saturating_add(1).saturating_sub(visible); - } - - let max_scroll = - crate::ui::agent_panel_scroll_metrics(self, detail_area).max_offset_from_bottom; - self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll); + self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( + self, + detail_area, + self.agent_panel_scroll, + idx, + ); } pub(crate) fn terminal_ids_for_workspace( @@ -2559,7 +2600,6 @@ impl AppState { agent_label, state, message, - custom_status, seq, session_ref, } => { @@ -2576,7 +2616,6 @@ impl AppState { agent_label, state, message, - custom_status, session_ref, seq, ) @@ -2611,11 +2650,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, seq, ttl, @@ -2627,11 +2664,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, ttl, seq, @@ -4578,7 +4613,6 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: None, session_ref: None, }); @@ -4617,7 +4651,6 @@ mod tests { agent_label: "codex".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: None, }); @@ -4666,7 +4699,6 @@ mod tests { agent_label: "claude".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"), }); @@ -4749,7 +4781,6 @@ mod tests { agent_label: "devin".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"), }); @@ -4774,7 +4805,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(20), session_ref: crate::agent_resume::AgentSessionRef::path(first_session), }); @@ -4787,7 +4817,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(21), session_ref: crate::agent_resume::AgentSessionRef::path(second_session), }); diff --git a/src/app/agents.rs b/src/app/agents.rs index bec375b9..83f97bd4 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -422,11 +422,13 @@ impl App { name: terminal.agent_name.clone(), agent: pane.agent, title: pane.title, + terminal_title: pane.terminal_title, + terminal_title_stripped: pane.terminal_title_stripped, display_agent: pane.display_agent, agent_status: pane.agent_status, screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(), - custom_status: pane.custom_status, state_labels: pane.state_labels, + tokens: pane.tokens, agent_session: pane.agent_session, workspace_id: pane.workspace_id, tab_id: pane.tab_id, diff --git a/src/app/api.rs b/src/app/api.rs index 9ebbbe56..c013d271 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -579,7 +579,6 @@ impl App { agent: update.agent_label.clone(), title: presentation.title, display_agent: presentation.display_agent, - custom_status: presentation.custom_status, state_labels: presentation.state_labels, }, }); @@ -745,6 +744,26 @@ impl App { self.event_hub.push(event); } + pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneUpdated, + data: crate::api::schema::EventData::PaneUpdated { pane }, + }); + } + } + + pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) { + // Token updates bypass plugin hooks so a hook cannot refresh its own + // token and recursively trigger workspace.updated. + self.event_hub.push(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceMetadataUpdated, + data: crate::api::schema::EventData::WorkspaceMetadataUpdated { + workspace: self.workspace_info(ws_idx), + }, + }); + } + pub(crate) fn sync_focus_events(&mut self) { let current_focus = self.state.active.and_then(|idx| { self.state @@ -816,6 +835,7 @@ impl App { &mut self, request: crate::api::schema::Request, ) -> String { + self.sync_terminal_titles(); use crate::api::schema::{ ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse, }; @@ -908,6 +928,9 @@ impl App { Method::WorkspaceMove(params) => { return self.handle_workspace_move(request.id, params); } + Method::WorkspaceReportMetadata(params) => { + return self.handle_workspace_report_metadata(request.id, params); + } Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 58dbeb19..17569682 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,15 +20,14 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, + normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, + MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; +#[cfg(test)] +use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; use super::responses::{encode_error, encode_success}; -const METADATA_SOURCE_MAX_CHARS: usize = 80; -const METADATA_TTL_MIN_MS: u64 = 1; -const METADATA_TTL_MAX_MS: u64 = 86_400_000; - impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { @@ -1237,7 +1236,6 @@ 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, }); @@ -1279,7 +1277,7 @@ impl App { id: String, params: PaneReportMetadataParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + 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() { @@ -1295,15 +1293,21 @@ impl App { }; 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 tokens = if params.tokens.is_empty() { + None + } else { + match normalize_metadata_tokens(params.tokens) { + Ok(tokens) => Some(tokens), + Err(message) => return encode_error(id, "invalid_metadata_token", message), + } + }; let ttl = match normalize_metadata_ttl(params.ttl_ms) { Ok(ttl) => ttl, Err(message) => return encode_error(id, "invalid_metadata_ttl", message), }; 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) => match normalize_metadata_source(applies_to_source) { Ok(applies_to_source) => Some(applies_to_source), @@ -1323,7 +1327,6 @@ impl App { }; 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( @@ -1334,11 +1337,10 @@ impl App { } if title.is_none() && display_agent.is_none() - && custom_status.is_none() && state_labels.is_empty() + && tokens.is_none() && !params.clear_title && !params.clear_display_agent - && !params.clear_custom_status && !params.clear_state_labels { return encode_error( @@ -1347,23 +1349,85 @@ impl App { "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, + let presentation_requested = title.is_some() + || display_agent.is_some() + || !state_labels.is_empty() + || params.clear_title + || params.clear_display_agent + || params.clear_state_labels; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.pane_state(pane_id)) + .map(|pane| pane.attached_terminal_id.clone()) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { + return encode_success(id, ResponseResult::Ok {}); + } + if let Some(tokens) = tokens.as_ref() { + if terminal.metadata_tokens.key_count_after_patch(tokens) + > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "pane metadata may contain at most {MAX_METADATA_TOKEN_KEYS_PER_RESOURCE} tokens" + ), + ); + } + } + match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "pane metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let token_changed = tokens.is_some_and(|tokens| { + let changed = terminal + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + terminal.revision = terminal.revision.saturating_add(1); + } + changed }); + if presentation_requested { + self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { + pane_id, + source, + agent_label, + applies_to_source, + title, + display_agent, + state_labels, + clear_title: params.clear_title, + clear_display_agent: params.clear_display_agent, + clear_state_labels: params.clear_state_labels, + seq: None, + ttl, + }); + } + if token_changed { + self.sync_agent_metadata_deadline(); + self.emit_pane_updated(ws_idx, pane_id); + } + encode_success(id, ResponseResult::Ok {}) } @@ -1551,41 +1615,6 @@ impl App { } } -fn normalize_metadata_source(value: String) -> Result<String, &'static str> { - let value = value.trim(); - if value.is_empty() { - return Err("metadata source must not be empty"); - } - if value.chars().count() > METADATA_SOURCE_MAX_CHARS { - return Err("metadata source must be 80 characters or fewer"); - } - if !value.chars().all(metadata_source_char_is_valid) { - return Err( - "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", - ); - } - Ok(value.to_string()) -} - -fn metadata_source_char_is_valid(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-') -} - -fn normalize_metadata_ttl( - ttl_ms: Option<u64>, -) -> Result<Option<std::time::Duration>, &'static str> { - let Some(ttl_ms) = ttl_ms else { - return Ok(None); - }; - if ttl_ms < METADATA_TTL_MIN_MS { - return Err("metadata ttl_ms must be at least 1"); - } - if ttl_ms > METADATA_TTL_MAX_MS { - return Err("metadata ttl_ms must be 86400000 or less"); - } - Ok(Some(std::time::Duration::from_millis(ttl_ms))) -} - fn normalize_presentation_text(value: Option<String>) -> Option<String> { let trimmed = value?.trim().to_string(); let normalized: String = trimmed @@ -1850,6 +1879,7 @@ mod tests { use crate::{ api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, + detect::{Agent, AgentState}, workspace::Workspace, }; @@ -1902,13 +1932,12 @@ mod tests { source: "user:metadata.test-1".into(), agent: None, applies_to_source: None, - title: None, + title: Some("activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, seq: None, ttl_ms: None, @@ -3584,6 +3613,116 @@ mod tests { } #[test] + fn pane_metadata_tokens_patch_and_clear_through_dispatcher() { + let (mut app, pane_id) = app_with_test_workspace(); + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("model".into(), Some("opus".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("model".into(), "opus".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("model".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let mut params = metadata_params(pane_id.clone()); + params.title = None; + params.tokens = tokens; + let response = app.handle_api_request(crate::api::schema::Request { + id: "set".into(), + method: crate::api::schema::Method::PaneReportMetadata(params), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "get".into(), + method: crate::api::schema::Method::PaneGet(PaneTarget { + pane_id: pane_id.clone(), + }), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info"); + }; + assert_eq!(pane.tokens, expected); + } + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty()); + } + + #[test] + fn pane_tokens_are_independent_from_presentation_guards() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Claude), AgentState::Working); + let mut params = metadata_params(pane_id); + params.title = None; + params.agent = Some("codex".into()); + params.tokens = + std::collections::HashMap::from([("summary".into(), Some("global".into()))]); + + let response = app.handle_pane_report_metadata("guarded".into(), params); + + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + app.state.terminals[&terminal_id].metadata_tokens.values(), + std::collections::HashMap::from([("summary".into(), "global".into())]) + ); + } + + #[test] + fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut presentation = metadata_params(pane_id.clone()); + presentation.seq = Some(10); + let response = app.handle_pane_report_metadata("presentation".into(), presentation); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut stale_token = metadata_params(pane_id.clone()); + stale_token.title = None; + stale_token.tokens = + std::collections::HashMap::from([("summary".into(), Some("stale".into()))]); + stale_token.seq = Some(9); + let response = app.handle_pane_report_metadata("stale".into(), stale_token); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id] + .metadata_tokens + .values() + .is_empty()); + } + + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index ead5c8e2..16d2fdb1 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -44,6 +44,7 @@ impl App { match &event.data { EventData::WorkspaceCreated { workspace } | EventData::WorkspaceUpdated { workspace } + | EventData::WorkspaceMetadataUpdated { workspace } | EventData::WorktreeCreated { workspace, .. } | EventData::WorktreeOpened { workspace, .. } => { self.plugin_context_for_workspace_info(workspace, correlation_id) @@ -132,7 +133,7 @@ impl App { context.tab_id = Some(layout.tab_id.clone()); context }), - EventData::PaneCreated { pane } => { + EventData::PaneCreated { pane } | EventData::PaneUpdated { pane } => { self.plugin_context_for_pane_info(pane, correlation_id) } EventData::PaneMoved { pane, .. } => { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b77b4715..1e0ec7d8 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -2369,7 +2369,6 @@ action = "missing" agent: "codex".into(), state: crate::api::schema::PaneAgentState::Working, message: None, - custom_status: None, seq: None, agent_session_id: None, agent_session_path: None, diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 8899ec44..b7260c17 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget, }; use crate::app::App; +use super::super::api_helpers::{normalize_metadata_source, normalize_metadata_ttl}; use super::responses::{encode_error, encode_success}; impl App { @@ -153,6 +154,76 @@ impl App { encode_success(id, ResponseResult::WorkspaceList { workspaces }) } + pub(super) fn handle_workspace_report_metadata( + &mut self, + id: String, + params: WorkspaceReportMetadataParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let source = match normalize_metadata_source(params.source) { + Ok(source) => source, + Err(message) => return encode_error(id, "invalid_metadata_source", message), + }; + let ttl = match normalize_metadata_ttl(params.ttl_ms) { + Ok(ttl) => ttl, + Err(message) => return encode_error(id, "invalid_metadata_ttl", message), + }; + let tokens = match super::super::api_helpers::normalize_metadata_tokens(params.tokens) { + Ok(tokens) => tokens, + Err(message) => return encode_error(id, "invalid_metadata_token", message), + }; + let Some(workspace) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + if !crate::metadata_tokens::sequence_is_fresh( + &workspace.metadata_token_sequences, + &source, + params.seq, + ) { + return encode_success(id, ResponseResult::Ok {}); + } + if workspace.metadata_tokens.key_count_after_patch(&tokens) + > super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "workspace metadata may contain at most {} tokens", + super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + ), + ); + } + match crate::metadata_tokens::accept_sequence( + &mut workspace.metadata_token_sequences, + &source, + params.seq, + ) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "workspace metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let changed = workspace + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + self.sync_agent_metadata_deadline(); + self.emit_workspace_token_updated(index); + } + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return workspace_not_found(id, &target.workspace_id); @@ -364,6 +435,90 @@ mod tests { } #[test] + fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("jj_status".into(), Some("2 changes".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("jj_status".into(), "2 changes".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("jj_status".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id: workspace_id.clone(), + source: "user:test".into(), + tokens, + seq: None, + ttl_ms: None, + }, + ), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(app.workspace_info(0).tokens, expected); + } + + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } + if workspace.tokens.get("summary").map(String::as_str) == Some("done") + && !workspace.tokens.contains_key("jj_status") + ))); + } + + #[test] + fn workspace_token_ttl_expires_through_runtime_and_emits_update() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + let response = app.handle_workspace_report_metadata( + "req".into(), + WorkspaceReportMetadataParams { + workspace_id, + source: "user:test".into(), + tokens: std::collections::HashMap::from([( + "summary".into(), + Some("temporary".into()), + )]), + seq: None, + ttl_ms: Some(1), + }, + ); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + let deadline = app.agent_metadata_deadline.expect("token deadline"); + + app.expire_metadata_at(deadline, deadline); + + assert!(app.workspace_info(0).tokens.is_empty()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } if workspace.tokens.is_empty() + ))); + } + + #[test] fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c07a431d..c0a98357 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -83,11 +83,118 @@ 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); +pub(super) const METADATA_TTL_MAX_MS: u64 = 86_400_000; +pub(super) const METADATA_SOURCE_MAX_CHARS: usize = 80; +const METADATA_TTL_MIN_MS: u64 = 1; +const MAX_METADATA_TOKEN_KEYS_PER_REQUEST: usize = 16; +pub(super) const MAX_METADATA_TOKEN_KEYS_PER_RESOURCE: usize = 32; +const MAX_METADATA_TOKEN_KEY_LEN: usize = 32; +const MAX_METADATA_TOKEN_VALUE_LEN: usize = 80; + +pub(super) fn normalize_metadata_source(value: String) -> Result<String, &'static str> { + let value = value.trim(); + if value.is_empty() { + return Err("metadata source must not be empty"); + } + if value.chars().count() > METADATA_SOURCE_MAX_CHARS { + return Err("metadata source must be 80 characters or fewer"); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')) + { + return Err( + "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", + ); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_metadata_ttl( + ttl_ms: Option<u64>, +) -> Result<Option<std::time::Duration>, &'static str> { + let Some(ttl_ms) = ttl_ms else { + return Ok(None); + }; + if ttl_ms < METADATA_TTL_MIN_MS { + return Err("metadata ttl_ms must be at least 1"); + } + if ttl_ms > METADATA_TTL_MAX_MS { + return Err("metadata ttl_ms must be 86400000 or less"); + } + Ok(Some(std::time::Duration::from_millis(ttl_ms))) +} + +pub(super) fn normalize_metadata_tokens( + tokens: std::collections::HashMap<String, Option<String>>, +) -> Result<std::collections::HashMap<String, Option<String>>, String> { + if tokens.is_empty() { + return Err("missing token to set or clear".into()); + } + if tokens.len() > MAX_METADATA_TOKEN_KEYS_PER_REQUEST { + return Err(format!( + "a metadata report may update at most {MAX_METADATA_TOKEN_KEYS_PER_REQUEST} tokens" + )); + } + + tokens + .into_iter() + .map(|(key, value)| { + if key.is_empty() + || key.len() > MAX_METADATA_TOKEN_KEY_LEN + || !key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + return Err(format!("invalid metadata token key: {key}")); + } + let value = value.and_then(|value| { + let normalized = value + .trim() + .chars() + .filter(|ch| !ch.is_control()) + .take(MAX_METADATA_TOKEN_VALUE_LEN) + .collect::<String>(); + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) + }); + Ok((key, value)) + }) + .collect() +} + +#[cfg(test)] +mod metadata_token_tests { + use super::*; + + #[test] + fn token_normalization_sanitizes_values_and_turns_empty_into_clear() { + let tokens = normalize_metadata_tokens(std::collections::HashMap::from([ + ("summary".into(), Some(" review\nready ".into())), + ("empty".into(), Some(" \n ".into())), + ("clear".into(), None), + ])) + .unwrap(); + + assert_eq!(tokens["summary"].as_deref(), Some("reviewready")); + assert_eq!(tokens["empty"], None); + assert_eq!(tokens["clear"], None); + } + + #[test] + fn token_normalization_rejects_invalid_or_unbounded_keys() { + for key in [ + "bad.name".to_string(), + "x".repeat(MAX_METADATA_TOKEN_KEY_LEN + 1), + ] { + assert!(normalize_metadata_tokens(std::collections::HashMap::from([( + key, + Some("value".into()), + )])) + .is_err()); + } + let too_many = (0..=MAX_METADATA_TOKEN_KEYS_PER_REQUEST) + .map(|index| (format!("key{index}"), Some("value".into()))) + .collect(); + assert!(normalize_metadata_tokens(too_many).is_err()); } - (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) } diff --git a/src/app/creation.rs b/src/app/creation.rs index 734c57aa..0277ba10 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -408,10 +408,12 @@ impl App { label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), title: presentation.title, + terminal_title: terminal.terminal_title.clone(), + terminal_title_stripped: terminal.terminal_title_stripped(), display_agent: presentation.display_agent, agent_status: pane_agent_status(terminal.state, pane.seen), - custom_status: presentation.custom_status, state_labels: presentation.state_labels, + tokens: terminal.metadata_tokens.values(), agent_session: terminal_agent_session_info(terminal), scroll, revision: terminal.revision, @@ -452,6 +454,7 @@ impl App { crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1) }), agent_status: pane_agent_status(agg_state, seen), + tokens: ws.metadata_tokens.values(), worktree: ws .worktree_space() .map(|space| crate::api::schema::WorkspaceWorktreeInfo { diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 32ad14b3..acbdfb9a 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -443,7 +443,7 @@ impl AppState { detail_area, crate::ui::should_show_scrollbar(metrics), ); - if body.height < 2 || row < body.y || row >= body.y + body.height { + if body.height == 0 || row < body.y || row >= body.y + body.height { return None; } @@ -452,13 +452,14 @@ impl AppState { .into_iter() .skip(self.agent_panel_scroll) { - if row_y.saturating_add(1) >= body.y + body.height { + let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height); + if row_y.saturating_add(height) > body.y + body.height { break; } - if row == row_y || row == row_y + 1 { + if row >= row_y && row < row_y.saturating_add(height) { return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); } - row_y = row_y.saturating_add(2); + row_y = row_y.saturating_add(height); if row_y < body.y + body.height { row_y = row_y.saturating_add(1); } @@ -692,6 +693,53 @@ mod tests { } #[test] + fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { + let mut app = app_for_mouse_test(); + let first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + let second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + for (ws_idx, pane_id, agent) in + [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] + { + let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); + let detail_area = app.state.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + + assert_eq!( + app.state.agent_detail_target_at(body.y), + Some((0, 0, first_pane)) + ); + assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); + assert_eq!( + app.state.agent_detail_target_at(body.y + 3), + Some((1, 0, second_pane)) + ); + } + + #[test] fn clicking_agent_panel_toggle_switches_sort() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; @@ -866,6 +914,14 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); app.state.agent_panel_scroll = 1; let detail_area = app.state.agent_panel_rect(); @@ -873,7 +929,7 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), body.x + 1, - body.y, + body.y + 1, )); assert_eq!(app.state.workspaces[0].active_tab, second_tab); diff --git a/src/app/mod.rs b/src/app/mod.rs index 99fc12a6..bff04c44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ mod runtime_mutations; mod session; pub mod state; mod terminal_targets; +mod terminal_titles; mod theme_sync; mod worktrees; @@ -598,6 +599,8 @@ impl App { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, + sidebar_agents: config.ui.sidebar.agents.clone(), + sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, copy_on_select: config.ui.copy_on_select, @@ -879,12 +882,19 @@ impl App { if self.render_dirty.load(Ordering::Acquire) { needs_render = true; } + let terminal_title_changed = self.sync_terminal_titles(); + if terminal_title_changed && self.terminal_title_sidebar_configured() { + needs_render = true; + } // Drain a bounded internal-event batch for responsiveness. API handlers // perform an exhaustive drain before reading pane/runtime state. if self.drain_internal_events() { needs_render = true; } + if self.expire_due_metadata(Instant::now()) { + needs_render = true; + } if self.drain_api_requests() { needs_render = true; } @@ -1390,6 +1400,8 @@ impl App { self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + self.state.sidebar_agents = config.ui.sidebar.agents.clone(); + self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); self.state.agent_panel_scroll = 0; self.state.accent = crate::config::parse_color(&config.ui.accent); if !self.state.local_sound_playback && self.state.sound != config.ui.sound { @@ -2686,6 +2698,59 @@ mod tests { } #[test] + fn reload_config_updates_sidebar_token_rows() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-sidebar-tokens"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n", + ) + .unwrap(); + app.state.agent_panel_scroll = 5; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.agent_panel_scroll, 0); + assert_eq!( + app.state.sidebar_agents.rows, + vec![vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::Custom("summary".into()), + ]] + ); + assert_eq!( + app.state.sidebar_agents.rows_by_agent["claude"], + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]] + ); + assert_eq!( + app.state.sidebar_spaces.rows, + vec![vec![ + crate::config::SpaceSidebarToken::Workspace, + crate::config::SpaceSidebarToken::Custom("jj_status".into()), + ]] + ); + + let previous_agents = app.state.sidebar_agents.clone(); + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"agent\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude-code = [[\"terminal_title\"]]\n", + ) + .unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.state.sidebar_agents, previous_agents); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_updates_sidebar_collapsed_mode() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-sidebar-collapsed-mode"); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 83ca2ea6..88081743 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -62,7 +62,8 @@ impl App { msg: crate::api::ApiRequestMessage, ) -> bool { let previous_mode = self.state.mode; - let mut changed = crate::api::request_changes_ui(&msg.request); + let mut changed = self.expire_due_metadata(Instant::now()); + changed |= crate::api::request_changes_ui(&msg.request); let skip_default_workspace = matches!( &msg.request.method, crate::api::schema::Method::ServerStop(_) @@ -288,18 +289,7 @@ impl App { self.start_background_session_save(); } - if let Some(deadline) = self - .agent_metadata_deadline - .filter(|deadline| now >= *deadline) - { - let previous_toast = self.state.toast.clone(); - for update in self.state.expire_agent_metadata_at(deadline, now) { - self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); - self.emit_pane_state_update(&update); - } - self.sync_agent_metadata_deadline(); - changed = true; - } + changed |= self.expire_due_metadata(now); if geometry_dirty || resized { self.pending_agent_resume_deadline = None; @@ -337,6 +327,33 @@ impl App { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } + pub(crate) fn expire_due_metadata(&mut self, now: Instant) -> bool { + let Some(deadline) = self + .agent_metadata_deadline + .filter(|deadline| now >= *deadline) + else { + return false; + }; + self.expire_metadata_at(deadline, now); + true + } + + pub(crate) fn expire_metadata_at(&mut self, deadline: Instant, now: Instant) { + let previous_toast = self.state.toast.clone(); + for update in self.state.expire_agent_metadata_at(deadline, now) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + } + let (panes, workspaces) = self.state.expire_metadata_tokens(now); + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + for ws_idx in workspaces { + self.emit_workspace_token_updated(ws_idx); + } + self.sync_agent_metadata_deadline(); + } + pub(crate) fn sync_animation_timer(&mut self, now: Instant) { self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL); } diff --git a/src/app/state.rs b/src/app/state.rs index be858678..d2315422 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1403,6 +1403,8 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, + pub sidebar_agents: crate::config::AgentsSidebarConfig, + pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only /// captures mouse while the focused pane app requests mouse reporting. @@ -1764,6 +1766,8 @@ impl AppState { sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, + sidebar_agents: crate::config::AgentsSidebarConfig::default(), + sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, mouse_capture: true, copy_on_select: true, diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs new file mode 100644 index 00000000..3c7d9711 --- /dev/null +++ b/src/app/terminal_titles.rs @@ -0,0 +1,148 @@ +use super::App; + +impl App { + pub(crate) fn terminal_title_sidebar_configured(&self) -> bool { + let config = &self.state.sidebar_agents; + std::iter::once(&config.rows) + .chain(config.rows_by_agent.values()) + .flatten() + .flatten() + .any(|token| { + matches!( + token, + crate::config::AgentSidebarToken::TerminalTitle + | crate::config::AgentSidebarToken::TerminalTitleStripped + ) + }) + } + + pub(crate) fn sync_terminal_titles(&mut self) -> bool { + let mut observations = Vec::new(); + for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { + for tab in &workspace.tabs { + for (pane_id, pane) in &tab.panes { + let terminal_id = &pane.attached_terminal_id; + let Some(runtime) = self.terminal_runtimes.get(terminal_id) else { + continue; + }; + observations.push(( + ws_idx, + *pane_id, + terminal_id.clone(), + runtime.terminal_title(), + )); + } + } + } + + let mut raw_changed = false; + let mut publish = Vec::new(); + for (ws_idx, pane_id, terminal_id, title) in observations { + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + continue; + }; + let change = terminal.set_terminal_title(title); + raw_changed |= change.raw_changed; + if change.stripped_changed { + publish.push((ws_idx, pane_id)); + } + } + + for (ws_idx, pane_id) in publish { + self.emit_pane_updated(ws_idx, pane_id); + } + + raw_changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::detect::{Agent, AgentState}; + use crate::workspace::Workspace; + + #[tokio::test] + async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.state = AgentState::Working; + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes()); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.title, None); + assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Working); + assert_eq!(pane.revision, 1); + let agent = app.collect_agent_infos().pop().unwrap(); + assert_eq!(agent.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(agent.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes()); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.revision, 1); + assert_eq!(pane_updated_events(&event_hub), 1); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;Done reviewing\x07"); + assert!(app.sync_terminal_titles()); + assert_eq!(pane_updated_events(&event_hub), 2); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;\x07"); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title, None); + assert_eq!(pane.terminal_title_stripped, None); + assert_eq!(pane.revision, 3); + assert_eq!(pane_updated_events(&event_hub), 3); + } + + #[test] + fn override_only_terminal_title_token_requests_sidebar_redraws() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]], + ); + + assert!(app.terminal_title_sidebar_configured()); + } + + fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated) + .count() + } +} |
