aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:26:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:30:13 +0300
commit5cfe5e5e730246948b0c9428ad876b6938590aad (patch)
tree53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src/app
parent7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff)
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs89
-rw-r--r--src/app/agents.rs4
-rw-r--r--src/app/api.rs25
-rw-r--r--src/app/api/panes.rs271
-rw-r--r--src/app/api/plugins/context.rs3
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/app/api/workspaces.rs157
-rw-r--r--src/app/api_helpers.rs119
-rw-r--r--src/app/creation.rs5
-rw-r--r--src/app/input/sidebar.rs66
-rw-r--r--src/app/mod.rs65
-rw-r--r--src/app/runtime.rs43
-rw-r--r--src/app/state.rs4
-rw-r--r--src/app/terminal_titles.rs148
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(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.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, &params.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()
+ }
+}