Files
herdr/src/client/shell/settings_overlay.rs
T
Can Celik 207be3c771 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
2026-09-01 15:48:22 +03:00

415 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
);
}
}