diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/agent_sidebar.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/agent_sidebar.rs')
| -rw-r--r-- | src/client/shell/agent_sidebar.rs | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs new file mode 100644 index 00000000..8f87b6ad --- /dev/null +++ b/src/client/shell/agent_sidebar.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::Line, + widgets::{Paragraph, Widget}, +}; + +use super::*; + +struct AgentRow { + pane_id: String, + status: crate::api::schema::AgentStatus, + focused: bool, + rows: Vec<Vec<crate::ui::ResolvedToken>>, +} + +pub(super) fn ordered_agent_pane_ids( + snapshot: &ClientShellSnapshot, + sort: crate::config::AgentPanelSortConfig, +) -> Vec<String> { + if snapshot.agent_view_label.is_some() { + return snapshot + .agent_order + .iter() + .filter(|pane_id| { + snapshot + .agents + .iter() + .any(|agent| agent.pane_id == pane_id.as_str()) + }) + .cloned() + .collect(); + } + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + agents.sort_by_key(|agent| { + ( + std::cmp::Reverse(status_priority(agent.agent_status)), + std::cmp::Reverse(agent.state_change_seq), + ) + }); + } + agents + .into_iter() + .map(|agent| agent.pane_id.clone()) + .collect() +} + +pub(super) fn render_agent_panel( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if area.height == 0 { + return; + } + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(area.width as usize), + Style::default().fg(config.palette.surface_dim), + ); + if area.height < 2 { + return; + } + put_text( + buffer, + area.x, + area.y + 1, + area.width, + " agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + let sort_label = + snapshot + .agent_view_label + .as_deref() + .unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); + let sort_width = display_width(sort_label).min(area.width as usize) as u16; + let sort_rect = Rect::new( + area.right().saturating_sub(sort_width), + area.y + 1, + sort_width, + 1, + ); + hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + sort_rect + } else { + Rect::default() + }; + put_text( + buffer, + sort_rect.x, + sort_rect.y, + sort_rect.width, + sort_label, + Style::default() + .fg(if snapshot.agent_view_label.is_some() { + config.palette.accent + } else { + config.palette.overlay0 + }) + .add_modifier(Modifier::BOLD), + ); + + let rows = agent_rows(snapshot, config); + let body = Rect::new( + area.x, + area.y.saturating_add(3), + area.width, + area.height.saturating_sub(3), + ); + hits.agent_body = body; + if body.is_empty() || rows.is_empty() { + *agent_scroll = 0; + if !body.is_empty() && snapshot.agent_view_label.is_some() { + put_text( + buffer, + body.x, + body.y, + body.width, + " no matching agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + return; + } + + let row_heights = rows + .iter() + .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .collect::<Vec<_>>(); + let gaps = rows + .iter() + .enumerate() + .map(|(index, _)| { + if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + } + }) + .collect::<Vec<_>>(); + let metrics = + super::scroll::list_scroll_metrics(&row_heights, &gaps, body.height, *agent_scroll); + hits.agent_max_scroll = metrics.max_offset_from_bottom; + hits.agent_scroll_metrics = Some(metrics); + *agent_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { + let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + y = y + .saturating_add(height) + .saturating_add(if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + }); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.agent_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, &config.palette); + } +} + +fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { + ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .filter_map(|pane_id| { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id)?; + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let pane = snapshot + .panes + .iter() + .find(|pane| pane.pane_id == agent.pane_id); + let tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + let tab_label = tab + .filter(|tab| tab_count > 1 || tab.custom_label) + .map(|tab| tab.label.as_str()); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .or(agent.title.as_deref()); + let labels = agent + .state_labels + .iter() + .cloned() + .collect::<HashMap<_, _>>(); + let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>(); + let state_text = labels + .get(status_text(agent.agent_status)) + .map(String::as_str) + .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); + let canonical_agent = agent + .agent + .as_deref() + .and_then(crate::detect::parse_agent_label); + let rows = crate::ui::sidebar_agent_rows( + &config.agents, + crate::ui::AgentTokenContext { + workspace: &workspace.label, + tab: tab_label, + pane: agent + .title + .as_deref() + .or_else(|| pane.and_then(|pane| pane.label.as_deref())), + agent_label, + terminal_title: agent.terminal_title.as_deref(), + terminal_title_stripped: agent.terminal_title_stripped.as_deref(), + canonical_agent, + tokens: &tokens, + }, + state_text, + ); + Some(AgentRow { + pane_id: agent.pane_id.clone(), + status: agent.agent_status, + focused: agent.focused, + rows, + }) + }) + .collect() +} + +fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { + let palette = &config.palette; + let row_style = if row.focused { + Style::default().bg(palette.active_row_bg) + } else { + Style::default() + }; + let name_style = if row.focused { + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(palette.subtext0) + .add_modifier(Modifier::BOLD) + }; + let status_style = Style::default() + .fg(status_color(row.status, palette)) + .add_modifier(if row.focused { + Modifier::empty() + } else { + Modifier::DIM + }); + let secondary = Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM); + let icon = ( + status_icon(row.status, config.status_indicators), + Style::default().fg(status_color(row.status, palette)), + ); + let rows = if row.rows.is_empty() { + vec![vec![crate::ui::ResolvedToken { + kind: crate::ui::ResolvedTokenKind::StateIcon, + style: Default::default(), + }]] + } else { + row.rows.clone() + }; + for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { + let indent = if index == 0 { 1 } else { 3 }; + let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; + spans.extend(crate::ui::resolved_token_spans( + tokens, + icon, + status_style, + name_style, + secondary, + secondary, + palette, + rect.width.saturating_sub(indent as u16) as usize, + )); + Paragraph::new(Line::from(spans)).style(row_style).render( + Rect::new(rect.x, rect.y + index as u16, rect.width, 1), + buffer, + ); + } +} + +fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + for (offset, character) in text.chars().take(width as usize).enumerate() { + if let Some(cell) = buffer.cell_mut((x + offset as u16, y)) { + cell.set_char(character).set_style(style); + } + } +} + +fn display_width(text: &str) -> usize { + unicode_width::UnicodeWidthStr::width(text) +} + +fn sidebar_status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Working => "working", + AgentStatus::Idle | AgentStatus::Unknown => "idle", + } +} |
