aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/settings_overlay.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/settings_overlay.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/settings_overlay.rs')
-rw-r--r--src/client/shell/settings_overlay.rs414
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),
+ );
+ }
+}