mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
* 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
415 lines
12 KiB
Rust
415 lines
12 KiB
Rust
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),
|
||
);
|
||
}
|
||
}
|