aboutsummaryrefslogtreecommitdiff
path: root/src/app/agent_view.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/agent_view.rs')
-rw-r--r--src/app/agent_view.rs573
1 files changed, 573 insertions, 0 deletions
diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs
new file mode 100644
index 00000000..ba50ee3a
--- /dev/null
+++ b/src/app/agent_view.rs
@@ -0,0 +1,573 @@
+use std::cmp::Ordering;
+
+use crate::api::schema::{
+ AgentStatus, AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewContext,
+ AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewSort, AgentViewSortField,
+ AgentViewSortOrder, AgentViewValue,
+};
+use crate::ui::AgentPanelEntry;
+
+use super::{AppState, Mode};
+
+const MAX_FILTER_DEPTH: usize = 8;
+const MAX_FILTER_NODES: usize = 64;
+const MAX_FILTER_VALUES: usize = 32;
+const MAX_SORT_FIELDS: usize = 8;
+const MAX_SOURCE_CHARS: usize = 120;
+const MAX_LABEL_CHARS: usize = 32;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum EvalValue {
+ String(String),
+ Bool(bool),
+ Number(u64),
+}
+
+pub(crate) fn validate_agent_view(spec: &mut AgentViewSetParams) -> Result<(), String> {
+ spec.source = normalize_source(&spec.source)?;
+ spec.label = spec
+ .label
+ .take()
+ .map(|label| normalize_label(&label))
+ .transpose()?;
+
+ let mut nodes = 0;
+ if let Some(filter) = &spec.filter {
+ validate_filter(filter, 1, &mut nodes)?;
+ }
+ if spec.sort.len() > MAX_SORT_FIELDS {
+ return Err(format!(
+ "agent view sort may contain at most {MAX_SORT_FIELDS} fields"
+ ));
+ }
+ for sort in &spec.sort {
+ validate_sort_field(&sort.field)?;
+ }
+ Ok(())
+}
+
+pub(crate) fn validate_agent_view_source(source: &str) -> Result<String, String> {
+ normalize_source(source)
+}
+
+pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec<AgentPanelEntry>) {
+ if let Some(spec) = app.agent_view_override.as_ref() {
+ if let Some(filter) = &spec.filter {
+ entries.retain(|entry| matches_filter(app, entry, filter));
+ }
+ if !spec.sort.is_empty() {
+ entries.sort_by(|left, right| compare_entries(app, left, right, &spec.sort));
+ return;
+ }
+ }
+
+ if matches!(
+ app.agent_panel_sort,
+ crate::app::state::AgentPanelSort::Priority
+ ) {
+ entries.sort_by_key(|entry| {
+ (
+ std::cmp::Reverse(super::api_helpers::tab_attention_priority(
+ entry.state,
+ entry.seen,
+ )),
+ std::cmp::Reverse(entry.last_agent_state_change_seq),
+ )
+ });
+ }
+}
+
+pub(crate) fn presented_workspace_idx(app: &AppState) -> Option<usize> {
+ if app.mode == Mode::Navigate {
+ app.workspaces.get(app.selected).map(|_| app.selected)
+ } else {
+ app.active
+ }
+}
+
+fn normalize_source(source: &str) -> Result<String, String> {
+ let source = source.trim();
+ if source.is_empty()
+ || source.chars().count() > MAX_SOURCE_CHARS
+ || !source
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-'))
+ {
+ return Err(format!(
+ "agent view source must be non-empty, at most {MAX_SOURCE_CHARS} characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen"
+ ));
+ }
+ Ok(source.to_string())
+}
+
+fn normalize_label(label: &str) -> Result<String, String> {
+ let label = label
+ .trim()
+ .chars()
+ .filter(|ch| !ch.is_control())
+ .collect::<String>();
+ if label.is_empty() || label.chars().count() > MAX_LABEL_CHARS {
+ return Err(format!(
+ "agent view label must be non-empty and at most {MAX_LABEL_CHARS} characters"
+ ));
+ }
+ Ok(label)
+}
+
+fn validate_filter(
+ filter: &AgentViewFilter,
+ depth: usize,
+ nodes: &mut usize,
+) -> Result<(), String> {
+ if depth > MAX_FILTER_DEPTH {
+ return Err(format!(
+ "agent view filter may be nested at most {MAX_FILTER_DEPTH} levels"
+ ));
+ }
+ *nodes += 1;
+ if *nodes > MAX_FILTER_NODES {
+ return Err(format!(
+ "agent view filter may contain at most {MAX_FILTER_NODES} nodes"
+ ));
+ }
+
+ match filter {
+ AgentViewFilter::All { filters } | AgentViewFilter::Any { filters } => {
+ if filters.is_empty() {
+ return Err("agent view all/any filters must not be empty".to_string());
+ }
+ for filter in filters {
+ validate_filter(filter, depth + 1, nodes)?;
+ }
+ }
+ AgentViewFilter::Not { filter } => validate_filter(filter, depth + 1, nodes)?,
+ AgentViewFilter::Eq { field, value } => validate_field_value(field, value)?,
+ AgentViewFilter::In { field, values } => {
+ if values.is_empty() || values.len() > MAX_FILTER_VALUES {
+ return Err(format!(
+ "agent view in filters require 1 to {MAX_FILTER_VALUES} values"
+ ));
+ }
+ for value in values {
+ validate_field_value(field, value)?;
+ }
+ }
+ AgentViewFilter::Exists { field } => validate_field(field)?,
+ }
+ Ok(())
+}
+
+fn validate_field(field: &AgentViewField) -> Result<(), String> {
+ if let AgentViewField::Token { token } = field {
+ validate_token(token)?;
+ }
+ Ok(())
+}
+
+fn validate_field_value(field: &AgentViewField, value: &AgentViewValue) -> Result<(), String> {
+ validate_field(field)?;
+ match (field, value) {
+ (
+ AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ )
+ | (
+ AgentViewField::Builtin(AgentViewBuiltinField::TabId),
+ AgentViewValue::Context {
+ context: AgentViewContext::CurrentTabId,
+ },
+ ) => Ok(()),
+ (_, AgentViewValue::Context { .. }) => {
+ Err("agent view context type does not match the selected field".to_string())
+ }
+ (AgentViewField::Builtin(AgentViewBuiltinField::Seen), AgentViewValue::Bool(_))
+ | (
+ AgentViewField::Builtin(AgentViewBuiltinField::StateChangeSeq),
+ AgentViewValue::Number(_),
+ ) => Ok(()),
+ (
+ AgentViewField::Builtin(
+ AgentViewBuiltinField::Status
+ | AgentViewBuiltinField::WorkspaceId
+ | AgentViewBuiltinField::TabId
+ | AgentViewBuiltinField::PaneId
+ | AgentViewBuiltinField::Agent,
+ )
+ | AgentViewField::Token { .. },
+ AgentViewValue::String(value),
+ ) => {
+ if matches!(
+ field,
+ AgentViewField::Builtin(AgentViewBuiltinField::Status)
+ ) && !matches!(
+ value.as_str(),
+ "idle" | "working" | "blocked" | "done" | "unknown"
+ ) {
+ return Err(format!("unknown agent status `{value}`"));
+ }
+ Ok(())
+ }
+ _ => Err("agent view value type does not match the selected field".to_string()),
+ }
+}
+
+fn validate_sort_field(field: &AgentViewSortField) -> Result<(), String> {
+ if let AgentViewSortField::Token { token } = field {
+ validate_token(token)?;
+ }
+ Ok(())
+}
+
+fn validate_token(token: &str) -> Result<(), String> {
+ if token.is_empty()
+ || token.len() > 32
+ || !token
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
+ {
+ return Err(format!("invalid agent view token `{token}`"));
+ }
+ Ok(())
+}
+
+fn matches_filter(app: &AppState, entry: &AgentPanelEntry, filter: &AgentViewFilter) -> bool {
+ match filter {
+ AgentViewFilter::All { filters } => filters
+ .iter()
+ .all(|filter| matches_filter(app, entry, filter)),
+ AgentViewFilter::Any { filters } => filters
+ .iter()
+ .any(|filter| matches_filter(app, entry, filter)),
+ AgentViewFilter::Not { filter } => !matches_filter(app, entry, filter),
+ AgentViewFilter::Eq { field, value } => {
+ field_value(app, entry, field) == operand_value(app, value)
+ }
+ AgentViewFilter::In { field, values } => {
+ let actual = field_value(app, entry, field);
+ values
+ .iter()
+ .any(|value| actual == operand_value(app, value))
+ }
+ AgentViewFilter::Exists { field } => field_value(app, entry, field).is_some(),
+ }
+}
+
+fn compare_entries(
+ app: &AppState,
+ left: &AgentPanelEntry,
+ right: &AgentPanelEntry,
+ sorts: &[AgentViewSort],
+) -> Ordering {
+ for sort in sorts {
+ let left = sort_value(app, left, &sort.field);
+ let right = sort_value(app, right, &sort.field);
+ let ordering = compare_optional_values(left, right, sort.order);
+ if ordering != Ordering::Equal {
+ return ordering;
+ }
+ }
+ Ordering::Equal
+}
+
+fn compare_optional_values(
+ left: Option<EvalValue>,
+ right: Option<EvalValue>,
+ order: AgentViewSortOrder,
+) -> Ordering {
+ match (left, right) {
+ (Some(left), Some(right)) => {
+ let ordering = left.cmp(&right);
+ if matches!(order, AgentViewSortOrder::Desc) {
+ ordering.reverse()
+ } else {
+ ordering
+ }
+ }
+ (Some(_), None) => Ordering::Less,
+ (None, Some(_)) => Ordering::Greater,
+ (None, None) => Ordering::Equal,
+ }
+}
+
+fn field_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: &AgentViewField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewField::Builtin(field) => builtin_field_value(app, entry, *field),
+ AgentViewField::Token { token } => entry.tokens.get(token).cloned().map(EvalValue::String),
+ }
+}
+
+fn builtin_field_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: AgentViewBuiltinField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewBuiltinField::Status => {
+ Some(EvalValue::String(status_name(entry.state, entry.seen)))
+ }
+ AgentViewBuiltinField::WorkspaceId => app
+ .workspaces
+ .get(entry.ws_idx)
+ .map(|workspace| EvalValue::String(workspace.id.clone())),
+ AgentViewBuiltinField::TabId => public_tab_id(app, entry).map(EvalValue::String),
+ AgentViewBuiltinField::PaneId => public_pane_id(app, entry).map(EvalValue::String),
+ AgentViewBuiltinField::Agent => entry.agent_kind_label.clone().map(EvalValue::String),
+ AgentViewBuiltinField::Seen => Some(EvalValue::Bool(entry.seen)),
+ AgentViewBuiltinField::StateChangeSeq => {
+ entry.last_agent_state_change_seq.map(EvalValue::Number)
+ }
+ }
+}
+
+fn operand_value(app: &AppState, value: &AgentViewValue) -> Option<EvalValue> {
+ match value {
+ AgentViewValue::String(value) => Some(EvalValue::String(value.clone())),
+ AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)),
+ AgentViewValue::Number(value) => Some(EvalValue::Number(*value)),
+ AgentViewValue::Context { context } => context_value(app, *context),
+ }
+}
+
+fn context_value(app: &AppState, context: AgentViewContext) -> Option<EvalValue> {
+ let ws_idx = presented_workspace_idx(app)?;
+ let workspace = app.workspaces.get(ws_idx)?;
+ match context {
+ AgentViewContext::CurrentWorkspaceId => Some(EvalValue::String(workspace.id.clone())),
+ AgentViewContext::CurrentTabId => {
+ let tab_number = workspace.public_tab_number(workspace.active_tab)?;
+ Some(EvalValue::String(
+ crate::workspace::public_tab_id_for_number(&workspace.id, tab_number),
+ ))
+ }
+ }
+}
+
+fn sort_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: &AgentViewSortField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewSortField::Token { token } => {
+ entry.tokens.get(token).cloned().map(EvalValue::String)
+ }
+ AgentViewSortField::Builtin(field) => match field {
+ AgentViewBuiltinSortField::WorkspaceOrder => {
+ Some(EvalValue::Number(entry.ws_idx as u64))
+ }
+ AgentViewBuiltinSortField::TabOrder => app
+ .workspaces
+ .get(entry.ws_idx)
+ .and_then(|workspace| workspace.public_tab_number(entry.tab_idx))
+ .map(|number| EvalValue::Number(number as u64)),
+ AgentViewBuiltinSortField::PaneOrder => app
+ .workspaces
+ .get(entry.ws_idx)
+ .and_then(|workspace| workspace.public_pane_number(entry.pane_id))
+ .map(|number| EvalValue::Number(number as u64)),
+ AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(u64::from(
+ super::api_helpers::tab_attention_priority(entry.state, entry.seen),
+ ))),
+ AgentViewBuiltinSortField::Status => {
+ Some(EvalValue::String(status_name(entry.state, entry.seen)))
+ }
+ AgentViewBuiltinSortField::Agent => {
+ entry.agent_kind_label.clone().map(EvalValue::String)
+ }
+ AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen)),
+ AgentViewBuiltinSortField::StateChangeSeq => {
+ entry.last_agent_state_change_seq.map(EvalValue::Number)
+ }
+ },
+ }
+}
+
+fn status_name(state: crate::detect::AgentState, seen: bool) -> String {
+ let status = match (state, seen) {
+ (crate::detect::AgentState::Idle, false) => AgentStatus::Done,
+ (crate::detect::AgentState::Idle, true) => AgentStatus::Idle,
+ (crate::detect::AgentState::Working, _) => AgentStatus::Working,
+ (crate::detect::AgentState::Blocked, _) => AgentStatus::Blocked,
+ (crate::detect::AgentState::Unknown, _) => AgentStatus::Unknown,
+ };
+ match status {
+ AgentStatus::Idle => "idle",
+ AgentStatus::Working => "working",
+ AgentStatus::Blocked => "blocked",
+ AgentStatus::Done => "done",
+ AgentStatus::Unknown => "unknown",
+ }
+ .to_string()
+}
+
+fn public_tab_id(app: &AppState, entry: &AgentPanelEntry) -> Option<String> {
+ let workspace = app.workspaces.get(entry.ws_idx)?;
+ let number = workspace.public_tab_number(entry.tab_idx)?;
+ Some(crate::workspace::public_tab_id_for_number(
+ &workspace.id,
+ number,
+ ))
+}
+
+fn public_pane_id(app: &AppState, entry: &AgentPanelEntry) -> Option<String> {
+ let workspace = app.workspaces.get(entry.ws_idx)?;
+ let number = workspace.public_pane_number(entry.pane_id)?;
+ Some(crate::workspace::public_pane_id_for_number(
+ &workspace.id,
+ number,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{AgentViewBuiltinSortField, AgentViewSortField};
+ use crate::detect::{Agent, AgentState};
+ use crate::workspace::Workspace;
+
+ fn state_with_agents() -> AppState {
+ let mut state = AppState::test_new();
+ state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ state.ensure_test_terminals();
+ state.active = Some(0);
+ state.selected = 0;
+ for (ws_idx, agent_state) in [(0, AgentState::Idle), (1, AgentState::Working)] {
+ let pane_id = state.workspaces[ws_idx].tabs[0].root_pane;
+ let terminal_id = state.workspaces[ws_idx].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(Agent::Claude);
+ terminal.state = agent_state;
+ }
+ state
+ }
+
+ fn current_workspace_view() -> AgentViewSetParams {
+ AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: Some("current".to_string()),
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ value: AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ }),
+ sort: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn current_workspace_filter_tracks_presented_workspace() {
+ let mut state = state_with_agents();
+ state.agent_view_override = Some(current_workspace_view());
+
+ assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0);
+
+ state.mode = Mode::Navigate;
+ state.selected = 1;
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].ws_idx, 1);
+
+ state.mode = Mode::Settings;
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].ws_idx, 0);
+ }
+
+ #[test]
+ fn boolean_filter_and_custom_sort_define_canonical_entries() {
+ let mut state = state_with_agents();
+ let first_pane = state.workspaces[0].tabs[0].root_pane;
+ let first_terminal = state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&first_terminal).unwrap().state = AgentState::Working;
+ state.agent_view_override = Some(AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::All {
+ filters: vec![
+ AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
+ value: AgentViewValue::String("working".to_string()),
+ },
+ AgentViewFilter::Not {
+ filter: Box::new(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ value: AgentViewValue::String("missing".to_string()),
+ }),
+ },
+ ],
+ }),
+ sort: vec![AgentViewSort {
+ field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::WorkspaceOrder),
+ order: AgentViewSortOrder::Desc,
+ }],
+ });
+
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].ws_idx, 1);
+ assert_eq!(entries[1].ws_idx, 0);
+ }
+
+ #[test]
+ fn agent_filter_matches_custom_lifecycle_agent_label() {
+ let mut state = state_with_agents();
+ let pane_id = state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "test".to_string(),
+ "custom-agent".to_string(),
+ AgentState::Working,
+ None,
+ None,
+ );
+ state.agent_view_override = Some(AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Agent),
+ value: AgentViewValue::String("custom-agent".to_string()),
+ }),
+ sort: Vec::new(),
+ });
+
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent"));
+ }
+
+ #[test]
+ fn validation_rejects_mismatched_context_type() {
+ let mut spec = AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
+ value: AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ }),
+ sort: Vec::new(),
+ };
+
+ assert!(validate_agent_view(&mut spec)
+ .unwrap_err()
+ .contains("context type"));
+ }
+}