diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index dcb338ad..498e13ab 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -696,6 +696,132 @@ pub enum PaneFocus { View, } +/// Stable identity of the bordered TUI surface that currently owns keyboard +/// focus. `PaneFocus` alone cannot distinguish sibling split windows or the +/// lineage section nested inside the list pane, both of which need their own +/// focus-acquisition affordance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FocusBorderTarget { + SessionList, + Lineage, + MainWindow(u64), +} + +/// Transient state for the focused-pane border sweep. The first observed +/// target establishes the baseline without animating; only a real focus +/// transition starts a sweep. +#[derive(Debug, Default)] +pub(crate) struct FocusBorderSweep { + target: Option, + started_at: Option, +} + +impl FocusBorderSweep { + pub(crate) fn observe( + &mut self, + target: FocusBorderTarget, + now: Instant, + ) -> Option { + match self.target { + None => { + self.target = Some(target); + return None; + } + Some(previous) if previous != target => { + self.target = Some(target); + self.started_at = Some(now); + } + Some(_) => {} + } + + let started_at = self.started_at?; + let elapsed = now.saturating_duration_since(started_at); + if elapsed >= Duration::from_millis(FOCUS_BORDER_SWEEP_MS) { + self.started_at = None; + return None; + } + // Reach the bottom-right endpoint on the final scheduled visible + // frame. The following cadence tick clears the overlay at 200 ms. + Some( + (elapsed.as_secs_f32() + / Duration::from_millis(FOCUS_BORDER_SWEEP_TRAVEL_MS).as_secs_f32()) + .min(1.0), + ) + } + + /// Keep target tracking current when its surface has no border (the + /// edge-to-edge zoomed view), without scheduling an invisible animation. + pub(crate) fn sync(&mut self, target: FocusBorderTarget) { + self.target = Some(target); + self.started_at = None; + } + + pub(crate) fn is_animating(&self, now: Instant) -> bool { + self.started_at.is_some_and(|started_at| { + now.saturating_duration_since(started_at) + < Duration::from_millis(FOCUS_BORDER_SWEEP_MS) + }) + } +} + +#[cfg(test)] +mod focus_border_sweep_tests { + use super::*; + + #[test] + fn first_focus_is_baseline_then_changes_sweep_for_two_hundred_ms() { + let mut sweep = FocusBorderSweep::default(); + let t0 = Instant::now(); + assert_eq!( + sweep.observe(FocusBorderTarget::MainWindow(1), t0), + None, + "startup focus should not flash" + ); + + assert_eq!(sweep.observe(FocusBorderTarget::SessionList, t0), Some(0.0)); + assert!(sweep.is_animating(t0 + Duration::from_millis(199))); + let halfway = sweep + .observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(80), + ) + .expect("sweep is active"); + assert!((halfway - 0.5).abs() < f32::EPSILON); + assert_eq!( + sweep.observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(160), + ), + Some(1.0), + "the last visible cadence frame reaches the bottom-right corner" + ); + assert_eq!( + sweep.observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(200), + ), + None + ); + assert!(!sweep.is_animating(t0 + Duration::from_millis(200))); + } + + #[test] + fn changing_split_windows_restarts_an_in_flight_sweep() { + let mut sweep = FocusBorderSweep::default(); + let t0 = Instant::now(); + sweep.sync(FocusBorderTarget::SessionList); + assert_eq!(sweep.observe(FocusBorderTarget::MainWindow(1), t0), Some(0.0)); + assert_eq!( + sweep.observe( + FocusBorderTarget::MainWindow(2), + t0 + Duration::from_millis(150), + ), + Some(0.0), + "the newly focused sibling starts at its own top-left corner" + ); + } +} + /// A spatial direction for moving keyboard focus between split panes /// (emacs `windmove`). Used by the `Shift+Arrow` bindings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2034,6 +2160,9 @@ pub struct App { /// frame. The 120 ms timer still drives animation and maintenance, but a /// static frame can leave its tick unpainted instead of rebuilding the TUI. tick_redraw_requested: Cell, + /// Brief directional highlight that identifies the pane which just took + /// keyboard focus. + pub(crate) focus_border_sweep: FocusBorderSweep, /// Set by `on_notification` to report whether the just-handled /// notification changed something currently *visible* (a focused / /// split pane, the minibuffer panel, or any structural / @@ -5158,6 +5287,13 @@ pub const PTY_QUIESCENCE: Duration = Duration::from_millis(600); /// Spinner frame cadence — fast enough to feel alive, slow enough to keep /// the TUI tick loop cheap. pub const SPINNER_FRAME_MS: u128 = 120; +/// Duration and dedicated frame cadence for the pane focus-border sweep. The +/// ordinary spinner tick stays at 120 ms; this faster timer is polled only +/// while a sweep is actually visible. +pub const FOCUS_BORDER_SWEEP_MS: u64 = 200; +const FOCUS_BORDER_SWEEP_FRAME_MS: u64 = 40; +const FOCUS_BORDER_SWEEP_TRAVEL_MS: u64 = + FOCUS_BORDER_SWEEP_MS - FOCUS_BORDER_SWEEP_FRAME_MS; /// Pulsing-star spinner: a 4-glyph sparkle whose size "breathes" via a /// palindromic frame schedule (small → big → small). Single cell wide so /// it slots into the same column as the static state glyph. @@ -5538,6 +5674,7 @@ async fn run_with_socket_initial_selection( operator_view_scroll: 0, skip_redraw_after_event: false, tick_redraw_requested: Cell::new(false), + focus_border_sweep: FocusBorderSweep::default(), notification_dirtied_view: true, hydrating_sessions: HashSet::new(), minibuffer_scrollback: 0, @@ -5921,6 +6058,12 @@ async fn run_loop( // Tick at the spinner frame boundary. Visible animations request paints; // static frames use the same wakeup only for maintenance. let mut tick = tokio::time::interval(Duration::from_millis(SPINNER_FRAME_MS as u64)); + // A focus sweep needs more than the spinner tick's one intermediate frame + // to read as directional. This timer is gated off outside the 200 ms + // animation window, so idle/static TUIs retain the existing wake cadence. + let mut focus_border_tick = + tokio::time::interval(Duration::from_millis(FOCUS_BORDER_SWEEP_FRAME_MS)); + focus_border_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // Lineage preview, keyboard-focused mode (spec 0080; supersedes the old // `C-x q` / `q` popup, spec 0139): its per-node elapsed-time/cost stats // are recomputed at render time from `SessionSummary` fields that are @@ -6398,6 +6541,10 @@ async fn run_loop( } } } + _ = focus_border_tick.tick(), if app.focus_border_sweep.is_animating(Instant::now()) => { + // Waking the loop is sufficient: the next iteration paints + // the sweep at its current monotonic-clock position. + } // Gate on `reconnect.is_none()`: once the daemon drops, the // old `notifications` channel is closed, so `recv()` is // *immediately* ready with `None` on every poll. Left @@ -6816,6 +6963,14 @@ fn op_xy_slot_state_masks(sessions: &[SessionSummary], slots: &[Option]) } impl App { + pub(crate) fn focused_border_target(&self) -> FocusBorderTarget { + match self.focus { + PaneFocus::List if self.lineage_focused => FocusBorderTarget::Lineage, + PaneFocus::List => FocusBorderTarget::SessionList, + PaneFocus::View => FocusBorderTarget::MainWindow(self.active_window_id), + } + } + async fn reconnect( &mut self, socket: &std::path::Path, @@ -17538,6 +17693,7 @@ mod tests { operator_view_scroll: 0, skip_redraw_after_event: false, tick_redraw_requested: Cell::new(false), + focus_border_sweep: FocusBorderSweep::default(), notification_dirtied_view: true, hydrating_sessions: HashSet::new(), minibuffer_scrollback: 0, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 75d79690..6013e3a0 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -2,8 +2,8 @@ use crate::app::{ feature_guidance, harness_guidance, harness_picker_entries, smith_method_guidance, App, - ConfigureTab, HarnessHit, HintZone, ListItem as AppListItem, MainWindowTree, Prompt, - PromptChoiceAction, PromptChoiceHit, PromptIntent, PaneFocus, RemoteControlHit, + ConfigureTab, FocusBorderTarget, HarnessHit, HintZone, ListItem as AppListItem, MainWindowTree, + PaneFocus, Prompt, PromptChoiceAction, PromptChoiceHit, PromptIntent, RemoteControlHit, RemoteControlHitAction, ScreenPoint, Selection, OperatorTitleMenuAction, SessionTitleMenuAction, TextSelectionRange, TurnRowHit, ViewMode, WindowDividerHit, WindowPaneHit, WindowSplitDirection, ZoomMode, CONFIGURE_TABS, PLAYBOOK_AGENT_COLLAB_CURSOR_TTL_MS, @@ -457,6 +457,7 @@ pub fn render(f: &mut Frame, app: &mut App) { // them after Playbooks and transitions so a rolled-down document cannot // cover the badge at its owning pane's top-left corner. paint_main_window_ordinal_badges(f, app); + render_focus_border_sweep(f, app, Instant::now()); // The block is complete: slide it, translate everything it recorded into // screen coordinates, and hand the pointer back. Everything below paints @@ -2387,6 +2388,9 @@ fn render_zoomed_view(f: &mut Frame, area: Rect, app: &mut App) { ViewMode::Chat => render_chat(f, main_area, app), } } + // Zoomed views are deliberately borderless. Sync the focus identity so + // returning to a bordered layout cannot replay a stale transition. + render_focus_border_sweep(f, app, Instant::now()); apply_main_block_slide(f, app, &slide); app.mouse_pos = screen_mouse; render_prompt(f, prompt_area, app); @@ -2417,6 +2421,7 @@ fn render_zoomed_list(f: &mut Frame, area: Rect, app: &mut App) { app.layout.list_scroll_offset = 0; render_sessions(f, main_area, app); + render_focus_border_sweep(f, app, Instant::now()); apply_main_block_slide(f, app, &slide); app.mouse_pos = screen_mouse; render_prompt(f, prompt_area, app); @@ -15103,6 +15108,218 @@ fn pane_border_style(theme: &Theme, focused: bool) -> Style { } } +/// Paint the moving bright band for a newly focused pane after that pane and +/// its title chrome are fully composited. The overlay temporarily draws all +/// four edges even when the steady pane chrome hides some of them. `(x + y) / +/// 2` supplies the phase, so the band intersects the top/left edges first and +/// the bottom/right edges last: a diagonal top-left → bottom-right sweep. +fn render_focus_border_sweep(f: &mut Frame, app: &mut App, now: Instant) { + let target = app.focused_border_target(); + let rect = match target { + FocusBorderTarget::SessionList => app.layout.list_area, + FocusBorderTarget::Lineage => app.layout.lineage_area, + FocusBorderTarget::MainWindow(id) => app + .layout + .main_window_areas + .iter() + .find(|pane| pane.id == id) + .map(|pane| pane.area), + }; + let Some(rect) = rect.filter(|rect| rect.width > 0 && rect.height > 0) else { + app.focus_border_sweep.sync(target); + return; + }; + let Some(progress) = app.focus_border_sweep.observe(target, now) else { + return; + }; + app.request_tick_redraw(); + + paint_focus_border_sweep( + f, + rect, + progress, + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM | Modifier::REVERSED), + ); +} + +fn paint_focus_border_sweep(f: &mut Frame, rect: Rect, progress: f32, highlight: Style) { + if rect.width == 0 || rect.height == 0 { + return; + } + const BAND_HALF_WIDTH: f32 = 0.14; + let x_denom = rect.width.saturating_sub(1).max(1) as f32; + let y_denom = rect.height.saturating_sub(1).max(1) as f32; + for y in rect.top()..rect.bottom() { + for x in rect.left()..rect.right() { + let on_edge = y == rect.top() + || y == rect.bottom().saturating_sub(1) + || x == rect.left() + || x == rect.right().saturating_sub(1); + if !on_edge { + continue; + } + let nx = x.saturating_sub(rect.left()) as f32 / x_denom; + let ny = y.saturating_sub(rect.top()) as f32 / y_denom; + let phase = (nx + ny) * 0.5; + if (phase - progress).abs() > BAND_HALF_WIDTH { + continue; + } + if let Some(cell) = f.buffer_mut().cell_mut(Position { x, y }) { + let is_corner = (x == rect.left() || x == rect.right().saturating_sub(1)) + && (y == rect.top() || y == rect.bottom().saturating_sub(1)); + // Pane titles and their live edit controls occupy the top + // border row. Highlight only actual horizontal-rule cells + // there; hidden side/bottom edges are blank and deliberately + // receive transient glyphs below. Corners always participate. + if y == rect.top() && !is_corner && cell.symbol() != "─" { + continue; + } + let symbol = match ( + x == rect.left(), + x == rect.right().saturating_sub(1), + y == rect.top(), + y == rect.bottom().saturating_sub(1), + ) { + (true, _, true, _) => "┌", + (_, true, true, _) => "┐", + (true, _, _, true) => "└", + (_, true, _, true) => "┘", + (_, _, true, _) | (_, _, _, true) => "─", + _ => "│", + }; + cell.set_symbol(symbol); + cell.set_style(highlight); + // `Cell::set_style` patches the existing cell and therefore + // cannot clear a modifier inherited from the pane beneath it. + // The focus cue is the glyph foreground, never reverse video. + cell.modifier.remove(Modifier::DIM | Modifier::REVERSED); + } + } + } +} + +#[cfg(test)] +mod focus_border_paint_tests { + use super::*; + use ratatui::backend::TestBackend; + + #[test] + fn full_border_band_crosses_top_right_and_bottom_left_halfway() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.5, + Style::default().fg(Color::Red), + ); + }) + .expect("draw"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(10, 0)].fg, Color::Red); + assert_eq!(buffer[(0, 10)].fg, Color::Red); + assert_ne!(buffer[(0, 0)].fg, Color::Red); + assert_ne!(buffer[(10, 10)].fg, Color::Red); + } + + #[test] + fn sweep_draws_full_foreground_border_over_hidden_edges() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + f.buffer_mut() + .cell_mut(Position { x: 5, y: 0 }) + .expect("top rule") + .set_symbol("─"); + f.buffer_mut() + .cell_mut(Position { x: 0, y: 5 }) + .expect("left edge") + .set_style( + Style::default() + .fg(Color::Green) + .bg(Color::Yellow) + .add_modifier(Modifier::REVERSED), + ); + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.25, + Style::default().fg(Color::Blue), + ); + }) + .expect("draw"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(5, 0)].symbol(), "─"); + assert_eq!(buffer[(5, 0)].fg, Color::Blue); + assert_eq!(buffer[(0, 5)].symbol(), "│"); + assert_eq!(buffer[(0, 5)].fg, Color::Blue); + assert_eq!(buffer[(0, 5)].bg, Color::Yellow); + assert!(!buffer[(0, 5)].modifier.contains(Modifier::REVERSED)); + assert_eq!(buffer[(0, 0)].symbol(), " "); + } + + #[test] + fn sweep_draws_corner_glyphs_at_both_diagonal_endpoints() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.0, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw first endpoint"); + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "┌"); + + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 1.0, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw second endpoint"); + assert_eq!(terminal.backend().buffer()[(10, 10)].symbol(), "┘"); + } + + #[test] + fn sweep_does_not_overwrite_title_text_in_top_border() { + let backend = TestBackend::new(22, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + f.buffer_mut().set_string( + 1, + 0, + " rename me ", + Style::default().fg(Color::Yellow), + ); + paint_focus_border_sweep( + f, + Rect::new(0, 0, 21, 11), + 0.25, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw"); + let title = (1..12) + .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) + .collect::(); + assert_eq!(title, " rename me "); + } +} + /// Style for the title text of a session pane. The last-focused pane keeps /// the focused border hue even when focus sits on the session list — the /// border dims, the name doesn't — so the pane `C-x o` returns to stays diff --git a/specs/0203-tui-focus-border-sweep.md b/specs/0203-tui-focus-border-sweep.md new file mode 100644 index 00000000..5719baac --- /dev/null +++ b/specs/0203-tui-focus-border-sweep.md @@ -0,0 +1,33 @@ +# 0203-tui-focus-border-sweep + +Status: accepted +Date: 2026-08-18 +Area: tui +Scope: Keyboard focus acquisition is acknowledged by a brief directional highlight around the newly focused pane's full perimeter. + +## Decision + +When keyboard focus moves to a different TUI pane or focusable sidebar section, that surface plays a roughly 200 ms border highlight sweeping from its top-left toward its bottom-right, then settles into the ordinary focused-border appearance. + +Focus identity includes the session-list rows, the lineage section, and each split window independently. Moving between sibling split windows therefore retriggers the animation even though both use the same general view-focus route. The initial pane at TUI startup does not animate. + +The sweep temporarily draws all four border edges, including their corners, even when the pane's steady chrome hides its side and bottom borders or exposes only a header rule. The highlight changes the border line's foreground color and weight; it does not fill or reverse the cells' backgrounds. Embedded titles and live title-edit text remain readable and are not replaced by the sweep. Once the sweep passes, the pane immediately returns to its configured steady border visibility. + +The effect must not change pane geometry, content layout, terminal size, or input routing. Edge-to-edge borderless zoom views do not animate. + +## Reason + +Static border-color changes are easy to miss when focus jumps among similarly shaped panes, especially in a split layout. A short directional motion gives the eye a clear acquisition cue without becoming a persistent distraction or delaying interaction. + +## Consequences + +- Every keyboard, mouse, or external-controller path that changes the existing focus state receives the same affordance because rendering derives it from focus identity rather than from individual input handlers. +- The animation uses monotonic time and requests frames only while visible; an otherwise idle TUI keeps its normal redraw cadence. +- Focus changes during an active sweep restart the effect on the newest target. +- Pane-specific border hues and steady focused/unfocused semantics remain authoritative after the sweep ends. + +## Non-Goals + +- Animating selection changes within a focused pane. +- Persistently adding borders to borderless or intentionally hidden-edge layouts. +- Changing focus order, keybindings, or mouse behavior.