aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/agent_sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/agent_sidebar.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs343
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",
+ }
+}