fix(ui): keep muted sidebar and tab labels readable (#4062)

refs #2692
This commit is contained in:
JJ Liebig
2026-09-13 18:17:42 +02:00
committed by GitHub
parent 981fe83ca2
commit 5f26879bcd
7 changed files with 134 additions and 20 deletions
+2 -10
View File
@@ -332,16 +332,8 @@ pub(super) fn render_agent_row(
.fg(palette.subtext0)
.add_modifier(Modifier::BOLD)
};
let status_style = Style::default()
.fg(status_color(row.status, palette))
.add_modifier(if row.focused {
Modifier::empty()
} else {
Modifier::DIM
});
let secondary = Style::default()
.fg(palette.overlay0)
.add_modifier(Modifier::DIM);
let status_style = Style::default().fg(status_color(row.status, palette));
let secondary = Style::default().fg(palette.overlay0);
let icon = (
status_icon(row.status, config.status_indicators),
Style::default().fg(status_color(row.status, palette)),
+1 -3
View File
@@ -703,9 +703,7 @@ pub(in crate::client::shell) fn render_workspace_rows(
status_icon(status, indicators),
Style::default().fg(status_color(status, palette)),
),
Style::default()
.fg(status_color(status, palette))
.add_modifier(Modifier::DIM),
Style::default().fg(status_color(status, palette)),
workspace_style,
secondary_style,
Style::default().fg(palette.overlay1),
+1 -4
View File
@@ -112,10 +112,7 @@ pub(crate) fn render_tab_bar(
} else if tab.custom_label {
Style::default().fg(palette.overlay1).bg(palette.surface0)
} else {
Style::default()
.fg(palette.overlay0)
.bg(palette.surface0)
.add_modifier(Modifier::DIM)
Style::default().fg(palette.overlay0).bg(palette.surface0)
};
let padding = width.saturating_sub(display_width(&name));
let left = padding / 2;
@@ -571,6 +571,67 @@ fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() {
assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg);
}
#[test]
fn muted_agent_sidebar_rows_do_not_stack_terminal_faint() {
let mut projected = snapshot();
projected.tabs[0].label = "second".into();
projected.tabs[0].custom_label = true;
projected.agents = vec![ClientShellAgent {
pane_id: "pane_1".into(),
workspace_id: "ws_1".into(),
tab_id: "tab_1".into(),
name: Some("reviewer".into()),
display_agent: None,
agent: Some("pi".into()),
title: None,
terminal_title: None,
terminal_title_stripped: None,
agent_status: AgentStatus::Working,
state_change_seq: 1,
state_labels: Vec::new(),
tokens: Vec::new(),
focused: true,
}];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(projected));
state.set_pane_surface(surface());
let frame = state.compose(106, 30).expect("agent sidebar frame");
let row = state.hits.agents.first().expect("agent row hit").0;
let buffer = frame.to_ratatui_buffer().expect("agent sidebar buffer");
for (label, needle) in [("tab", "second"), ("agent", "reviewer"), ("separator", "·")] {
let (x, y) = cell_symbol_position(&frame, row, needle);
let cell = buffer.cell((x, y)).expect("muted sidebar cell");
assert!(
!cell.modifier.contains(Modifier::DIM),
"{label} cell at ({x},{y}) should not stack terminal faint: {cell:?}"
);
}
}
#[test]
fn workspace_state_text_does_not_stack_terminal_faint() {
use crate::config::SpaceSidebarToken;
let mut config = Config::default();
config.ui.sidebar.spaces.rows = vec![
vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace],
vec![SpaceSidebarToken::StateText],
];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
state.set_snapshot(Box::new(snapshot()));
state.set_pane_surface(surface());
let frame = state.compose(106, 30).expect("workspace sidebar frame");
let rect = state.hits.workspaces.first().expect("workspace hit").rect;
let buffer = frame.to_ratatui_buffer().expect("workspace sidebar buffer");
let (x, y) = cell_symbol_position(&frame, rect, "idle");
let cell = buffer.cell((x, y)).expect("workspace state text cell");
assert!(
!cell.modifier.contains(Modifier::DIM),
"workspace state text at ({x},{y}) should not stack terminal faint: {cell:?}"
);
}
#[test]
fn active_agent_view_controls_sidebar_order_and_focus_indices() {
let mut projected = snapshot();
@@ -164,6 +164,39 @@ fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() {
assert!(!top.contains("ZOOM · host"));
}
#[test]
fn inactive_auto_named_tab_label_does_not_stack_terminal_faint() {
let mut projected = snapshot();
projected.tabs.push(ClientShellTab {
tab_id: "tab_2".into(),
workspace_id: "ws_1".into(),
number: 2,
label: "beta".into(),
custom_label: false,
zoomed: false,
focused: false,
agent_status: AgentStatus::Idle,
});
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(projected));
state.set_pane_surface(surface());
let frame = state.compose(106, 20).expect("tab bar frame");
let rect = state
.hits
.tabs
.iter()
.find(|(_, tab_id)| tab_id == "tab_2")
.expect("inactive tab hit")
.0;
let buffer = frame.to_ratatui_buffer().expect("tab bar buffer");
let (x, y) = cell_symbol_position(&frame, rect, "beta");
let cell = buffer.cell((x, y)).expect("inactive tab cell");
assert!(
!cell.modifier.contains(Modifier::DIM),
"inactive tab label at ({x},{y}) should not stack terminal faint: {cell:?}"
);
}
#[test]
fn configured_prefix_is_client_owned_and_renders_its_bar() {
let config = toml::from_str::<Config>(
+35
View File
@@ -133,6 +133,41 @@ fn surface() -> PaneSurfaceFrame {
}
}
fn frame_rows(frame: &FrameData) -> Vec<String> {
frame
.cells
.chunks(frame.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect())
.collect()
}
/// Absolute cell position of `needle` inside `area`, for style assertions.
fn cell_symbol_position(frame: &FrameData, area: Rect, needle: &str) -> (u16, u16) {
let rows = frame_rows(frame);
for y in area.y..area.bottom().min(frame.height) {
let row = &rows[y as usize];
let slice = row
.chars()
.skip(area.x as usize)
.take(area.width as usize)
.collect::<String>();
if let Some(byte) = slice.find(needle) {
let column = slice[..byte].chars().count() as u16 + area.x;
return (column, y);
}
}
let visible = (area.y..area.bottom().min(frame.height))
.map(|y| {
rows[y as usize]
.chars()
.skip(area.x as usize)
.take(area.width as usize)
.collect::<String>()
})
.collect::<Vec<_>>();
panic!("symbol {needle:?} not found in {area:?}: {visible:?}");
}
fn pane_scroll_result(
offset_from_bottom: u64,
max_offset_from_bottom: u64,
+1 -3
View File
@@ -219,9 +219,7 @@ pub(crate) fn resolved_token_spans(
let previous = &resolved[visible_indices[position - 1]];
spans.push(Span::styled(
tokens::separator(previous, token),
Style::default()
.fg(palette.overlay0)
.add_modifier(Modifier::DIM),
Style::default().fg(palette.overlay0),
));
}
match &token.kind {