Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- The desktop tab bar now shows a right-aligned ZOOM indicator while the focused pane is zoomed.
- Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode.
- Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows.
- Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option.
Expand Down
87 changes: 86 additions & 1 deletion src/ui/tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::app::AppState;
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
const ZOOM_INDICATOR: &str = " ZOOM ";

#[derive(Debug, Clone, Default)]
pub(crate) struct TabBarView {
Expand All @@ -22,6 +23,14 @@ pub(crate) struct TabBarView {
pub new_tab_hit_area: Rect,
}

fn zoom_indicator_width(ws: &crate::workspace::Workspace) -> u16 {
if ws.zoomed {
display_width_u16(ZOOM_INDICATOR).saturating_add(1)
} else {
0
}
}

fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 {
display_width_u16(&tab_chrome_label(ws, tab_idx))
.saturating_add(4)
Expand Down Expand Up @@ -118,6 +127,16 @@ pub(crate) fn compute_tab_bar_view(
return TabBarView::default();
}

// Reserve the right edge for the zoom indicator so tabs and trailing
// controls never render underneath it.
let area = Rect {
width: area.width.saturating_sub(zoom_indicator_width(ws)),
..area
};
if area.width == 0 {
return TabBarView::default();
}

if !mouse_chrome {
let max_scroll = max_tab_scroll(ws, area);
let scroll = if follow_active {
Expand Down Expand Up @@ -380,17 +399,32 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
let content_right = area.x + area.width.saturating_sub(zoom_indicator_width(ws));
let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
} else {
area.x + area.width.saturating_sub(1)
content_right.saturating_sub(1)
};
if x >= area.x && x < area.x + area.width {
frame.buffer_mut()[(x, area.y)]
.set_symbol("…")
.set_style(Style::default().fg(p.overlay0));
}
}

if ws.zoomed {
let width = display_width_u16(ZOOM_INDICATOR).min(area.width);
let rect = Rect::new(area.x + area.width - width, area.y, width, 1);
frame.render_widget(
Paragraph::new(ZOOM_INDICATOR).style(
Style::default()
.fg(panel_contrast_fg(p))
.bg(p.accent)
.add_modifier(Modifier::BOLD),
),
rect,
);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -438,6 +472,57 @@ mod tests {
);
}

#[test]
fn tab_bar_shows_zoom_indicator_at_right_edge_when_active_tab_is_zoomed() {
let mut app = AppState::test_new();
let mut ws = Workspace::test_new("test");
ws.tabs[0].zoomed = true;

app.workspaces = vec![ws];
app.active = Some(0);
app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
app.view.tab_hit_areas = view.tab_hit_areas.clone();

let backend = TestBackend::new(30, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
.unwrap();

let buffer = terminal.backend().buffer();
let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0);
assert!(row.ends_with(" ZOOM"), "tab row: {row:?}");
assert_eq!(buffer[(28, 0)].style().bg, Some(app.palette.accent));

// Tabs never render underneath the reserved indicator area.
let indicator_x = 30 - display_width_u16(ZOOM_INDICATOR) - 1;
for rect in &view.tab_hit_areas {
assert!(rect.x + rect.width <= indicator_x, "tab overlaps indicator");
}
}

#[test]
fn tab_bar_omits_zoom_indicator_when_active_tab_is_not_zoomed() {
let mut app = AppState::test_new();
let ws = Workspace::test_new("test");

app.workspaces = vec![ws];
app.active = Some(0);
app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
app.view.tab_hit_areas = view.tab_hit_areas;

let backend = TestBackend::new(30, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
.unwrap();

let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
assert!(!row.contains("ZOOM"), "tab row: {row:?}");
}

#[test]
fn active_auto_named_tab_keeps_readable_weight() {
let mut app = AppState::test_new();
Expand Down
Loading