aboutsummaryrefslogtreecommitdiff
path: root/src/ui/status.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/ui/status.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/ui/status.rs')
-rw-r--r--src/ui/status.rs294
1 files changed, 42 insertions, 252 deletions
diff --git a/src/ui/status.rs b/src/ui/status.rs
index 62646482..e47e0f90 100644
--- a/src/ui/status.rs
+++ b/src/ui/status.rs
@@ -1,17 +1,15 @@
use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
+ buffer::Buffer,
+ layout::Rect,
+ style::{Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, Paragraph},
- Frame,
+ widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-use super::text::display_width_u16;
use super::widgets::panel_contrast_fg;
use crate::{
- app::state::{CopyFeedback, Palette, ToastKind, ToastNotification},
- config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition},
- detect::AgentState,
+ app::state::{CopyFeedback, Palette},
+ config::ToastClipboardPosition,
};
pub(crate) fn copy_feedback_rect(
@@ -20,7 +18,7 @@ pub(crate) fn copy_feedback_rect(
offset_rows: u16,
position: ToastClipboardPosition,
) -> Rect {
- if area.width == 0 || area.height == 0 {
+ if area.is_empty() {
return Rect::default();
}
@@ -49,129 +47,56 @@ pub(crate) fn copy_feedback_rect(
Rect::new(x, y, width, height)
}
-pub(crate) fn toast_notification_rect(
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- position: ToastHerdrPosition,
-) -> Rect {
- let content_width = display_width_u16(&toast.title)
- .max(display_width_u16(&toast.context))
- .saturating_add(4);
- let width = content_width.saturating_add(2).min(area.width);
- let content_height = if toast.context.is_empty() { 1 } else { 2 };
- let height = (content_height + 2).min(area.height);
- let x = match position {
- ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x,
- ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => {
- area.x + area.width.saturating_sub(width)
- }
- };
- let warning_offset = u16::from(offset_for_warning);
- let y = match position {
- ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => {
- area.y + warning_offset.min(area.height)
- }
- ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => {
- area.y + area.height.saturating_sub(height + warning_offset)
- }
- };
- Rect::new(x, y, width, height)
-}
-
-pub(super) fn render_toast_notification(
- frame: &mut Frame,
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- position: ToastHerdrPosition,
- p: &Palette,
-) {
- let dot_color = match toast.kind {
- ToastKind::NeedsAttention => p.red,
- ToastKind::Finished => p.blue,
- ToastKind::UpdateInstalled => p.accent,
- };
- let toast_area = toast_notification_rect(area, toast, offset_for_warning, position);
-
- frame.render_widget(Clear, toast_area);
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(p.overlay0))
- .style(Style::default().bg(p.panel_bg));
- let inner = block.inner(toast_area);
- frame.render_widget(block, toast_area);
-
- if inner.height < 1 {
- return;
- }
-
- let [title_row, context_row] =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
-
- let title = Line::from(vec![
- Span::styled("●", Style::default().fg(dot_color)),
- Span::raw(" "),
- Span::styled(
- &toast.title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- ),
- ]);
- let context = Line::from(vec![
- Span::styled(" ", Style::default().fg(p.overlay0)),
- Span::styled(&toast.context, Style::default().fg(p.overlay0)),
- ]);
-
- frame.render_widget(Paragraph::new(title), title_row);
- if !toast.context.is_empty() && inner.height >= 2 {
- frame.render_widget(Paragraph::new(context), context_row);
- }
-}
-
-pub(super) fn render_copy_feedback(
- frame: &mut Frame,
+pub(crate) fn render_copy_feedback_buffer(
+ buffer: &mut Buffer,
area: Rect,
feedback: &CopyFeedback,
offset_rows: u16,
position: ToastClipboardPosition,
- p: &Palette,
+ palette: &Palette,
) {
let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position);
if feedback_area.is_empty() {
return;
}
- frame.render_widget(Clear, feedback_area);
+ Clear.render(feedback_area, buffer);
let block = Block::default()
.borders(Borders::ALL)
- .border_style(Style::default().fg(p.green))
- .style(Style::default().bg(p.panel_bg));
+ .border_style(Style::default().fg(palette.green))
+ .style(Style::default().bg(palette.panel_bg));
let inner = block.inner(feedback_area);
- frame.render_widget(block, feedback_area);
+ block.render(feedback_area, buffer);
if inner.height == 0 {
return;
}
let text = Line::from(vec![
- Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)),
+ Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)),
Span::raw(" "),
Span::styled(
&feedback.message,
Style::default()
- .fg(p.text)
- .bg(p.panel_bg)
+ .fg(palette.text)
+ .bg(palette.panel_bg)
.add_modifier(Modifier::BOLD),
),
]);
- frame.render_widget(Paragraph::new(text), inner);
+ Paragraph::new(text).render(inner, buffer);
}
-pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
+pub(crate) fn render_config_diagnostic_buffer(
+ buffer: &mut Buffer,
+ area: Rect,
+ message: &str,
+ palette: &Palette,
+) -> u16 {
let style = Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.yellow)
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.yellow)
.add_modifier(Modifier::BOLD);
+ let mut rendered_rows = 0u16;
for (row, line) in message
.lines()
@@ -181,176 +106,41 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &
{
let text = format!(" {line} ");
let width = (text.len() as u16).min(area.width);
- let notif_area = Rect::new(
+ let diagnostic_area = Rect::new(
area.x + area.width.saturating_sub(width),
area.y + row as u16,
width,
1,
);
- frame.render_widget(Clear, notif_area);
- frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area);
+ Clear.render(diagnostic_area, buffer);
+ Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer);
+ rendered_rows = rendered_rows.saturating_add(1);
}
-}
-pub(super) fn state_icon_symbol(
- state: AgentState,
- seen: bool,
- indicator_style: StatusIndicatorStyle,
-) -> &'static str {
- match (indicator_style, state, seen) {
- (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○",
- (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·",
- (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×",
- (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐",
- (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓",
- (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○",
- (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·",
- }
-}
-
-pub(super) fn state_icon(
- state: AgentState,
- seen: bool,
- indicator_style: StatusIndicatorStyle,
- p: &Palette,
-) -> (&'static str, Style) {
- (
- state_icon_symbol(state, seen, indicator_style),
- Style::default().fg(state_label_color(state, seen, p)),
- )
-}
-
-pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (AgentState::Blocked, _) => "blocked",
- (AgentState::Working, _) => "working",
- (AgentState::Idle, false) => "done",
- (AgentState::Idle, true) => "idle",
- (AgentState::Unknown, _) => "idle",
- }
-}
-
-pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
- match (state, seen) {
- (AgentState::Blocked, _) => p.red,
- (AgentState::Working, _) => p.yellow,
- (AgentState::Idle, false) => p.teal,
- (AgentState::Idle, true) => p.green,
- (AgentState::Unknown, _) => p.overlay0,
- }
+ rendered_rows
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::config::{ToastClipboardPosition, ToastHerdrPosition};
-
- fn toast() -> ToastNotification {
- ToastNotification {
- kind: ToastKind::Finished,
- title: "done".to_string(),
- context: "workspace".to_string(),
- position: None,
- target: None,
- }
- }
-
- fn feedback() -> CopyFeedback {
- CopyFeedback {
- message: "copied to clipboard".to_string(),
- }
- }
-
- #[test]
- fn state_icons_support_dot_and_distinct_symbol_styles() {
- let palette = Palette::catppuccin();
- for (indicator_style, expected_symbols) in [
- (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]),
- (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]),
- ] {
- for ((state, seen, color), expected_symbol) in [
- (AgentState::Blocked, true, palette.red),
- (AgentState::Working, true, palette.yellow),
- (AgentState::Idle, false, palette.teal),
- (AgentState::Idle, true, palette.green),
- (AgentState::Unknown, true, palette.overlay0),
- ]
- .into_iter()
- .zip(expected_symbols)
- {
- let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette);
- assert_eq!(actual_symbol, expected_symbol);
- assert_eq!(display_width_u16(actual_symbol), 1);
- assert_eq!(style.fg, Some(color));
- }
- }
- }
-
- #[test]
- fn toast_rect_uses_configured_corner() {
- let area = Rect::new(10, 20, 100, 40);
- let toast = toast();
-
- let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft);
- assert_eq!(top_left.x, area.x);
- assert_eq!(top_left.y, area.y);
-
- let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight);
- assert_eq!(top_right.x + top_right.width, area.x + area.width);
- assert_eq!(top_right.y, area.y);
-
- let bottom_left =
- toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft);
- assert_eq!(bottom_left.x, area.x);
- assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height);
-
- let bottom_right =
- toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight);
- assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width);
- assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height);
- }
-
- #[test]
- fn toast_rect_uses_display_width_for_cjk_labels() {
- let area = Rect::new(0, 0, 100, 20);
- let toast = ToastNotification {
- kind: ToastKind::NeedsAttention,
- title: "重构用户认证模块".to_string(),
- context: "提交 herdr 的反馈".to_string(),
- position: None,
- target: None,
- };
-
- let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight);
-
- let expected_content_width =
- display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6;
- assert_eq!(rect.width, expected_content_width);
- assert_eq!(rect.x + rect.width, area.x + area.width);
- }
#[test]
fn copy_feedback_rect_uses_configured_position() {
let area = Rect::new(10, 20, 100, 40);
- let feedback = feedback();
+ let feedback = CopyFeedback {
+ message: "copied to clipboard".to_owned(),
+ };
- let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter);
- assert_eq!(top_center.y, area.y);
- assert_eq!(
- top_center.x,
- area.x + area.width.saturating_sub(top_center.width) / 2
- );
+ let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter);
+ assert_eq!(top.y, area.y);
+ assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2);
- let bottom_center =
- copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter);
- assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height);
+ let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter);
+ assert_eq!(bottom.bottom(), area.bottom());
assert_eq!(
- bottom_center.x,
- area.x + area.width.saturating_sub(bottom_center.width) / 2
+ bottom.x,
+ area.x + area.width.saturating_sub(bottom.width) / 2
);
}
}