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/settings_overlay.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/settings_overlay.rs')
| -rw-r--r-- | src/client/shell/settings_overlay.rs | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs new file mode 100644 index 00000000..ea59ad02 --- /dev/null +++ b/src/client/shell/settings_overlay.rs @@ -0,0 +1,414 @@ +use super::*; + +fn choice_style(selected: bool, palette: &Palette) -> Style { + if selected { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + } +} + +fn draw_choice( + buffer: &mut Buffer, + rect: Rect, + label: &str, + selected: bool, + current: bool, + palette: &Palette, +) { + let style = choice_style(selected, palette); + buffer.set_style(rect, style); + let marker = if selected { "▸" } else { " " }; + let current = if current { " ✓" } else { "" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!(" {marker} {label}{current}"), + style, + ); +} + +pub(super) fn render_settings_overlay( + buffer: &mut Buffer, + settings: &ClientSettingsOverlay, + integration_updates_available: bool, + palette: &Palette, +) -> Option<OverlayRender> { + let integration_height = 14u16 + .saturating_add(settings.integrations.len().max(1) as u16) + .saturating_add(settings.integration_messages.len().min(6) as u16); + let height = if settings.section == ClientSettingsSection::Integrations { + integration_height.max(22) + } else { + 22 + }; + let popup = popup(buffer.area, 76, height)?; + let inner = panel(buffer, popup, palette.accent, palette.panel_bg)?; + if inner.width < 20 || inner.height < 8 { + return None; + } + + put_text( + buffer, + inner.x, + inner.y, + inner.width, + " settings", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + + let integration_badge = integration_updates_available + || settings + .integrations + .iter() + .any(|integration| integration.state == crate::api::schema::IntegrationState::Outdated); + let mut tab_x = inner.x; + let mut tab_hits = Vec::new(); + for section in ClientSettingsSection::ALL { + let badge = *section == ClientSettingsSection::Integrations && integration_badge; + let label = if badge { + format!(" ● {} ", section.label()) + } else { + format!(" {} ", section.label()) + }; + let width = display_width(&label).min(inner.right().saturating_sub(tab_x)); + let rect = Rect::new(tab_x, inner.y + 1, width, 1); + let active = *section == settings.section; + let style = if active { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + buffer.set_style(rect, style); + put_text(buffer, rect.x, rect.y, rect.width, &label, style); + if badge && !active { + put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(1).min(2), + "● ", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + tab_hits.push((rect, *section)); + tab_x = tab_x.saturating_add(width.saturating_add(1)); + if tab_x >= inner.right() { + break; + } + } + put_text( + buffer, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(palette.surface0).bg(palette.panel_bg), + ); + + let content = Rect::new( + inner.x, + inner.y + 4, + inner.width, + inner.height.saturating_sub(7), + ); + let mut choice_hits = Vec::new(); + match settings.section { + ClientSettingsSection::Theme => { + let visible = usize::from(content.height); + let scroll = settings.selected.saturating_sub(visible.saturating_sub(1)); + for (visible_index, (index, name)) in crate::config::THEME_NAMES + .iter() + .enumerate() + .skip(scroll) + .take(visible) + .enumerate() + { + let rect = Rect::new( + content.x, + content.y + visible_index as u16, + content.width, + 1, + ); + draw_choice( + buffer, + rect, + name, + index == settings.selected, + super::super::settings::normalized_theme_name(name) + == super::super::settings::normalized_theme_name( + &settings.original_theme_name, + ), + palette, + ); + choice_hits.push((rect, index)); + } + } + ClientSettingsSection::Indicators => { + render_choice_section( + buffer, + content, + "agent status indicators", + "choose color dots or distinct symbols for each state", + &["color dots ● ● ● ○ ·", "distinct symbols × ◐ ✓ ○ ·"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Sound => { + render_choice_section( + buffer, + content, + "sound alerts", + "play sounds when agents change state in background", + &["on", "off"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Toast => { + render_choice_section( + buffer, + content, + "notification popups", + "choose where background popup notifications should appear", + &["off", "inside herdr", "via terminal", "via system"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Integrations => { + render_integrations(buffer, content, settings, palette); + } + } + + let installable = settings + .integrations + .iter() + .any(super::super::settings::integration_needs_install); + let show_primary = settings.section != ClientSettingsSection::Integrations || installable; + let labels = if show_primary { vec![10, 12] } else { vec![12] }; + let buttons = row(inner, &labels, 2, inner.height.saturating_sub(1)); + let (primary, close) = if show_primary { + let primary = buttons[0]; + button( + buffer, + primary, + if settings.section == ClientSettingsSection::Integrations { + " ↵ install " + } else { + " ↵ apply " + }, + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + (primary, buttons[1]) + } else { + (Rect::default(), buttons[0]) + }; + button( + buffer, + close, + " esc close ", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + inner.x, + inner.bottom().saturating_sub(2), + inner.width, + " ↑↓ select tab section", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + + Some(OverlayRender { + primary, + cancel: close, + settings_popup: popup, + settings_tabs: tab_hits, + settings_choices: choice_hits, + ..OverlayRender::default() + }) +} + +fn render_choice_section( + buffer: &mut Buffer, + area: Rect, + title: &str, + description: &str, + choices: &[&str], + selected: usize, + palette: &Palette, + hits: &mut Vec<(Rect, usize)>, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + title, + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + description, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + let row_gap = u16::from(choices.len() > 2); + for (index, choice) in choices.iter().enumerate() { + let y = area.y + 3 + index as u16 * (1 + row_gap); + if y >= area.bottom() { + break; + } + let rect = Rect::new(area.x, y, area.width, 1); + draw_choice(buffer, rect, choice, index == selected, false, palette); + hits.push((rect, index)); + } +} + +fn render_integrations( + buffer: &mut Buffer, + area: Rect, + settings: &ClientSettingsOverlay, + palette: &Palette, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + "agent integrations", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + "let agents report state directly instead of relying only on process detection", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if settings.loading_integrations { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " loading integrations…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + if settings.integrations.is_empty() { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " no integration targets available", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + for (index, integration) in settings.integrations.iter().enumerate() { + let y = area.y + 3 + index as u16; + if y >= area.bottom() { + break; + } + let (marker, color, status) = match integration.state { + crate::api::schema::IntegrationState::Current => ("✓", palette.green, "installed"), + crate::api::schema::IntegrationState::Outdated => { + ("↻", palette.yellow, "update available") + } + crate::api::schema::IntegrationState::NotInstalled if integration.available => { + ("+", palette.accent, "available") + } + crate::api::schema::IntegrationState::NotInstalled => { + ("–", palette.overlay0, "not found") + } + }; + put_text( + buffer, + area.x, + y, + 3, + &format!(" {marker}"), + Style::default().fg(color).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 3, + y, + 11.min(area.width.saturating_sub(3)), + &format!("{:<9}", integration.label), + Style::default().fg(palette.subtext0).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 14, + y, + area.width.saturating_sub(14), + status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + let message_y = area + .y + .saturating_add(4) + .saturating_add(settings.integrations.len() as u16); + for (offset, message) in settings.integration_messages.iter().take(6).enumerate() { + let y = message_y.saturating_add(offset as u16); + if y >= area.bottom() { + break; + } + put_text( + buffer, + area.x, + y, + area.width, + &format!(" {message}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + if settings.installing_integrations && message_y < area.bottom() { + put_text( + buffer, + area.x, + message_y, + area.width, + " installing…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } +} |
