diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eea377..fc36b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- Fixed the menu cursor disappearing when arrowing onto a disabled row — most visibly Orchestrator → "Show/Hide Session", which is disabled unless a CLI-class orchestrator session exists. Arrow keys already stepped over separators, but not over disabled rows, and the renderer draws those dim and never highlighted: the selection was still there, just invisible, and Enter did nothing. Both kinds of unselectable row are now stepped past. Relatedly, `Up` returns to the menu bar from the topmost row that can actually be selected rather than from literal index 0, which otherwise wrapped to the bottom of the list when the first row was a separator or disabled. + +- Fixed codex sessions having no scrollback at all. Both of the paths that could have supplied it produced nothing, for reasons that only look alike from a distance: the capture linkshell already had was gated on the session being on the alternate screen, which codex never enters, and the vt100 fallback it therefore fell through to was empty because codex scrolls inside a `DECSTBM` region — and lines evicted from a restricted region are discarded rather than pushed to scrollback, per the DEC spec and vt100's implementation of it. The transcript is now recovered from the region as it scrolls, for any session kind we capture for, on either screen. +- The recovered transcript also lives in its own buffer rather than sharing `output_lines` with pattern matching and pipe extraction. That buffer is the raw PTY line stream, which for a repainting TUI is mostly repaint fragments, and mixing the two put window-title escapes and pinned composer rows into the scrollback view. Sharing it had a second consequence: the scroll offset was clamped against the line stream, so it could run hundreds of lines past the end of the real transcript into a blank window. +- Fixed the scrollback going blank at the moment you reached the beginning of it, in every session type — the visible window was computed as `total - offset` and collapsed to nothing at the oldest line. Scrolling now stops at the oldest full page. + +- Output panes lost their boxes. Each now has a title line (`● alpha · ~/src/linkshell`, filled and bright when focused, hollow and dim when not) and a one-column bar down the left edge that does double duty: `▎` in the accent colour marks the focused pane, `│` in chrome separates a pane from its neighbour, so a split needs neither per-pane borders nor a separate divider. Dropping the bottom border is a row of output back. Content width is unchanged, deliberately — it is what the PTY is sized to and what mouse selection maps against, and those three had been three separate `saturating_sub(2)`s in three files, where a silently 2-column-wider PTY renders fine and mis-maps every drag. They now share one definition. The docked chat and planning panes wear the same chrome; the floating chat overlay keeps its box. + +- The status panel moved to a permanent left sidebar, and gained placements: `[general] status_panel` is `"left"` (default), `"bottom"`, `"overlay"` or `"off"`, with `alt-s` hiding and restoring a docked panel. The sidebar costs columns instead of rows — the better trade on a wide terminal — and having the full column height it does not run out of room at four sessions the way the bottom region does. Its rows are restacked vertically, because the horizontal row needs 70 columns and would leave 30 for output; a session with no model shows its working directory instead of an empty `-`. Below `status_panel_width + 60` columns it collapses to a three-column rail of state glyphs rather than starving the output pane: which agent needs you is the panel's real job, and a glyph plus an index still does it. +- Status rows are two lines now: vitals, then model, working directory, and pipe direction. Pipe direction is genuinely new — the old table gave it a fixed 20-column cell that truncated a second peer out of existence. The `│` column separators are gone in favour of fixed-width padding, which removes the whole class of drift that made the columns wander. The `"bottom"` placement past four sessions drops the detail line rather than showing fewer sessions; the `"overlay"` placement claims no layout space at all, so opening it resizes no PTY. + +- Added a footer row reporting the focused session — name, model, context, state (with a spinner while it works), elapsed, cost. Together with the tab strip's state glyphs this is what makes the status panel closable: it answers "what am I looking at, is it working, what has it cost" for the pane in front of you, which is what the panel was being kept open to answer. The context counter turns amber past 80% of the window, and renders a bare count with no denominator when the window size was never probed — there is no percentage to colour against, and inventing a threshold is worse than omitting one. Fields are shed as the terminal narrows rather than wrapping, which would cost a row of output. + +- The session bar is now a one-row tab strip above the output pane instead of three rows of bordered slot boxes below it, and it names sessions rather than kinds — three shells all reading "shell" was the case it most needed to disambiguate. State moved from the slot's border colour, which a single row has nowhere to put, to a suffix glyph: `!` for WAITING, `✕` for ERROR or dead, `⏸` for paused. That is what keeps "which agent wants me" legible with the status panel closed, which is the point of shrinking the chrome. As the terminal narrows the strip drops names before it drops tabs — inactive tabs first, then all of them — so every session keeps a clickable tab. + +- Added a `[theme]` table: `base` selects `classic` (the palette linkshell has always shipped), `dark` (a quieter restyle palette with a single accent colour), or `ansi16` (named ANSI colours only), and any individual colour is overridable as a hex string. With `base` unset the theme is picked from `COLORTERM`: truecolor terminals get `classic`, everything else gets `ansi16`, because a `Color::Rgb` on a 256-colour terminal is quantized to whatever is nearest — which is how a carefully-chosen "dim" ends up indistinguishable from "text". `linkshell doctor` now reports which base resolved and why. + - Fixed the menu bar's Down arrow doing nothing past the first press: it was wired to "open the submenu", which selects item 0 unconditionally, so every press re-selected the same row and the items below the first were unreachable by keyboard. Up now walks back up and pops out to the section row from the first item. This is what made the planning backend picker (Agenda → Planning Model, the second item) look like it did not exist. - Fixed the planning pane's context meter reporting `~0k` for any thread under 1000 tokens — `used / 1000` in integer arithmetic — which read as a meter that was not measuring anything rather than one reporting a small number. Counts below 1k now keep their digits. - The context meter also now reports what a turn actually sent. Tool results are consumed inside a turn and never persisted (deliberately — it is what keeps a thread portable between a 32k local model and a 200k hosted one), so a turn that read half the repository left a transcript of a few hundred tokens and a meter that barely moved. Each reply carries the peak request size back, shown as `~868/131k (peak 5.4k)`. The first number is still what the *next* turn starts from; the peak belongs to the turn that produced it and is not restored when a thread is reopened. diff --git a/docs/config-reference.md b/docs/config-reference.md index 4af091b..707b920 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -6,8 +6,43 @@ use these defaults. ## `[general]` `max_ipc_message_bytes = 0`, `scroll_buffer_lines = 2000`, -`tick_interval_ms = 100`, `ipc_state_override_timeout_secs = 60`, and -`menu_key = "ctrl+space"`. +`tick_interval_ms = 100`, `ipc_state_override_timeout_secs = 60`, +`menu_key = "ctrl+space"`, `status_panel = "left"`, and +`status_panel_width = 28`. + +`status_panel` is `"left"` (a permanent sidebar), `"bottom"` (the always-on +region below the output), `"overlay"` (alt-s only, claims no layout space) or +`"off"`. `status_panel_width` is the sidebar's width in columns, clamped to +16–60; below that width plus 60 columns of terminal the sidebar collapses to a +narrow rail. See [Status panel](panes-and-navigation.md#status-panel). + +## `[theme]` + +Every colour the UI draws with. `base` picks a palette; any individual field +overrides it with a `#rrggbb` hex string. + +```toml +[theme] +base = "dark" # "classic" | "dark" | "ansi16" +accent = "#5fb3d4" +``` + +- `classic` — the palette linkshell has always shipped. +- `dark` — the restyle palette: desaturated chrome, one accent colour used for + focus and nothing else. +- `ansi16` — named ANSI colours only, so a 16- or 256-colour terminal renders + *your* colour scheme instead of a quantized approximation of a truecolor one. + +`base` unset auto-detects: `classic` when `COLORTERM` contains `truecolor` or +`24bit`, `ansi16` otherwise. `TERM` is not consulted — it reads +`xterm-256color` on nearly everything, truecolor-capable or not. Run +`linkshell doctor` to see which base resolved and why. + +Overridable fields: `bg`, `surface`, `chrome`, `text`, `text_dim`, +`text_bright`, `accent`, `warn`, `err`, `ok`, `info`, `ctx`, `cost`, `pipe`, +`on_accent`, `sel_bg`, and the per-agent `kind_claude`, `kind_codex`, +`kind_opencode`, `kind_ohmypi`, `kind_aider`, `kind_shell`, `kind_custom`, +`kind_orch`. An unparseable value is reported on stderr and ignored. ## `[socket]` diff --git a/docs/panes-and-navigation.md b/docs/panes-and-navigation.md index dd25d8d..3d02f82 100644 --- a/docs/panes-and-navigation.md +++ b/docs/panes-and-navigation.md @@ -1,6 +1,8 @@ # Panes, Navigation & Keybindings - [Split panes](#split-panes) +- [Tab strip](#tab-strip) +- [Footer](#footer) - [Scrollback](#scrollback) - [Status panel](#status-panel) - [Keybindings](#keybindings) @@ -9,8 +11,22 @@ ## Split panes Split any pane side by side (`alt-\`) or top/bottom (`alt--`), repeatedly and in -any direction, for arbitrary tiled layouts. The session bar is centered and -reflows based on how many sessions are open. +any direction, for arbitrary tiled layouts. + +Panes have no boxes. Each carries a title line and a one-column bar down its +left edge, and that bar does double duty — `▎` in the accent colour marks the +focused pane, `│` in chrome separates a pane from its neighbour — so a split +needs neither per-pane borders nor a separate divider: + +``` +▎ ● alpha · ~/src/linkshell │ ○ beta · /tmp +▎ $ cargo test │ $ +``` + +A filled dot and a bright name mean focused; hollow and dim mean not. A pane +scrolled off the live tail says so in its title (`↑12`). The docked chat and +planning panes wear the same chrome; the floating chat overlay keeps its box, +because a floating thing needs an edge to read as floating. | Key | Action | |-----|--------| @@ -20,29 +36,123 @@ reflows based on how many sessions are open. | `alt-r` | Rotate the focused pane's split direction | | `alt-o` | Focus next pane | +## Tab strip + +One row above the output pane names every visible session: + +``` + 1 alpha 2 beta! 3 gamma✕ +``` + +The active tab is highlighted; a tab underlined is showing in some other split +pane. The suffix glyph is the session's state — `!` for WAITING, `✕` for ERROR +or a dead session, `⏸` for paused, nothing otherwise — so "which agent wants +me" is legible without the status panel open. Click a tab to focus it. + +As the terminal narrows the strip drops names before it drops tabs: first the +inactive tabs fall back to bare indices, then all of them do. Every session +keeps a tab. + +## Footer + +The bottom row reports the focused session: + +``` + claude · opus-4-8 18.0k/180k ⣾ THINKING 0:42 $0.31 alt-h help +``` + +The name is coloured by state — accent while working, amber on WAITING, red +on ERROR — and a dead session offers `alt-r restart`. The context counter +turns amber past 80% of the window; when the window size is unknown (a local +model that was never probed) it renders a bare count with no denominator, +because there is no percentage to colour against. A paused or dead session +greys the whole row: its numbers are frozen. + +Fields are dropped as the terminal narrows rather than wrapping, which would +cost a row of output. The hint goes first, then cost, then elapsed; the +session's name and state are the last to go. + ## Scrollback `alt-shift-PageUp/PageDown` (and `alt-shift-↑/↓`) scroll every session type the -same way. Shells use the terminal's native scrollback; full-screen TUIs (claude, -codex, opencode) scroll through linkshell's captured line history, shown dimmed. -The view holds position while new output streams in — typing returns you to the -live tail. +same way. The view holds position while new output streams in — typing returns +you to the live tail. + +Shells use the terminal's native scrollback. Agent TUIs (claude, codex) scroll +through a transcript linkshell recovers from the screen as it scrolls, shown +dimmed, because vt100 has no scrollback to offer them — for two different +reasons. Claude runs on the alternate screen, which has none by design. Codex +stays on the normal screen but scrolls inside a `DECSTBM` region, and lines +evicted from a restricted region are discarded rather than retained, per the +DEC spec. Scrolling stops at the oldest full page. Mouse text selection works everywhere: drag to select, auto-copies to clipboard. ## Status panel -Each session gets one row: +A permanent sidebar down the left, on by default. `alt-s` tucks it away and +brings it back. + +``` + Status │▎ ● alpha · ~/src/linkshell + ● alpha 1m32s │▎ $ cargo test + READY 18.0k/180k │▎ + opus-4-8 $0.31 │▎ + │▎ + ! beta 12s │▎ + WAITING 2.1k │▎ + sonnet-5 $0.04 │▎ + │ + 2 sess $0.35 │ +``` + +Each session is a stacked block: name and elapsed, state and context, then +model and cost. A session with no model (a shell) shows its working directory +instead — the thing that actually distinguishes it from the shell in the next +pane. Clicking a block focuses that session. + +The sidebar costs **columns instead of rows**, which is the better trade on a +wide terminal, and it has the full column height — so unlike the bottom +region it doesn't run out of room at four sessions. + +### When the terminal is narrow + +Below `status_panel_width + 60` columns the sidebar collapses to a +three-column rail rather than starving the output pane: ``` -1 🟠 →2 THINKING 1m 32s │ ~450 tok │ ~$0.02 +●1 ▎ ● alpha · ~/src/linkshell +!2 ▎ $ cargo test +✕3 ▎ +``` + +State still reaches you — which agent needs you is the sidebar's real job, and +a glyph plus an index still does it. + +### Placement + +```toml +[general] +status_panel = "left" # "left" (default) | "bottom" | "overlay" | "off" +status_panel_width = 28 # columns for the sidebar ``` -`→2` means this session has an active pipe to session 2. The arrow goes bold for -one second when the pipe fires. Token counts and cost come from the JSONL logs -written by Claude and Codex — not from screen scraping. Shell and custom -sessions show `—`. On Pro/Max subscriptions, linkshell detects the subscription -and shows real token counts while skipping meaningless cost. +- **`left`** — the sidebar above. +- **`bottom`** — the always-on region below the output, in two-line rows. + Its height is capped at a third of the terminal, so past four sessions it + drops the detail line to keep every session visible. +- **`overlay`** — not docked; `alt-s` opens it centered over the output, which + costs no layout space and resizes no PTY. +- **`off`** — never shown, and `alt-s` does nothing. + +In `left` and `bottom`, `alt-s` hides and restores the panel. Note that this +resizes the sessions' PTYs, as any docked panel must; `overlay` is the +placement that never does. + +Token counts and cost come from the JSONL logs written by Claude and Codex — +not from screen scraping. Shell and custom sessions show `—`. On Pro/Max +subscriptions, linkshell detects the subscription and shows real token counts +while skipping meaningless cost. ## Keybindings @@ -51,6 +161,7 @@ and shows real token counts while skipping meaningless cost. | `alt-n` | New session dialog | | `alt-c` | Open command bar | | `alt-t` | Toggle agent chat pane | +| `alt-s` | Show/hide the status panel | | `alt-h` | Toggle help | | `alt-x` | Kill active session | | `alt-d` | Detach (sessions keep running) | diff --git a/src/app.rs b/src/app.rs index 15a7f53..6dce4f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,6 +20,7 @@ use crate::pipe::{self, ExtractMode, Pipe, PipeTrigger}; use crate::session::{ extract_waiting_prompt, Session, SessionKind, SessionState, MAX_SESSIONS, PTY_COLS, PTY_ROWS, }; +use crate::theme::Theme; fn expand_home(path: &str) -> String { if path == "~" || path.starts_with("~/") { @@ -181,6 +182,10 @@ pub enum AppMode { OrchestratorModel { selected: usize, }, + /// The status panel as an overlay (alt-s). An overlay rather than a + /// split: opening it must not resize any PTY, or every agent repaints + /// each time you glance at the panel. + Status, Search { query: String, cursor: usize, @@ -219,6 +224,28 @@ impl SettingsState { } } +/// Where the status panel lives. See `[general] status_panel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusPlacement { + Left, + Bottom, + Overlay, + Off, +} + +impl StatusPlacement { + /// Unknown values fall back to the default rather than failing: a typo in + /// one optional cosmetic key should not stop linkshell starting. + pub fn parse(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "bottom" | "docked" => Self::Bottom, + "overlay" => Self::Overlay, + "off" | "none" | "hidden" => Self::Off, + _ => Self::Left, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PipeGlyph { pub outgoing: bool, @@ -541,6 +568,14 @@ pub struct App { status_rows_hold_at: std::cell::Cell>, pub event_tx: mpsc::Sender, pub config: Arc, + /// Runtime alt-s state for a docked panel. Not persisted: the config + /// says where the panel lives, this says whether you have tucked it away + /// for the moment. + pub status_hidden: bool, + /// Every colour the UI draws with. Lives on App because each `draw_*` fn + /// already takes `&App`, so the palette is a field access rather than a + /// parameter threaded through forty signatures. + pub theme: Theme, pub pipes: Vec, // Current PTY size derived from each output pane (rows, cols), one per // pane slot; length matches `panes`. @@ -688,6 +723,8 @@ impl App { status_rows_hold: std::cell::Cell::new(0), status_rows_hold_at: std::cell::Cell::new(None), event_tx, + status_hidden: false, + theme: Theme::resolve(&config.theme), config, pipes: Vec::new(), pane_sizes: vec![(PTY_ROWS, PTY_COLS)], @@ -1372,15 +1409,36 @@ impl App { } } - /// Unified scrollback. Normal-screen apps (shells) use vt100's native - /// scrollback; full-screen TUIs (claude, codex, opencode, ...) occupy the - /// alternate screen where vt100 keeps none, so we scroll through our own - /// captured `output_lines` history instead. Same keys, every session type. + /// Unified scrollback. Shells use vt100's native scrollback; agent TUIs + /// scroll through our own captured `output_lines` history, because vt100 + /// has none to offer them — claude lives on the alternate screen, and + /// codex scrolls inside a DECSTBM region, whose evicted lines the spec + /// says to discard. Same keys, every session type. + /// + /// The kind test is in addition to `alternate_screen()`, not instead of + /// it: on its own, `alternate_screen()` is what made codex unscrollable, + /// since codex is a normal-screen app and so took the vt100 branch where + /// there was never anything to find. An alt-screen shell (vim, less) + /// still reaches the history captured before it started. pub fn scroll_up(&mut self, lines: usize) { + // The visible height, so the offset stops at the oldest full page + // rather than at the oldest line. Scrolling past that point moves the + // indicator without moving the view, which reads as the scrollback + // having silently stopped working. + let visible = self + .pane_sizes + .get(self.focused_pane) + .map(|(rows, _)| *rows as usize) + .unwrap_or(0); if let Some(idx) = self.active_idx() { if let Some(session) = self.sessions.get_mut(idx) { - if session.screen.screen().alternate_screen() { - let max = session.output_lines.len(); + if session.kind.captures_scrollback() || session.screen.screen().alternate_screen() + { + // Clamp against the buffer the view actually renders. For + // an agent TUI that is the captured transcript, not the + // raw line stream — which is far longer, and would let + // the offset run past the end into a blank window. + let max = session.history_lines().len().saturating_sub(visible); session.history_scroll = (session.history_scroll + lines).min(max); } else { let current = session.screen.screen().scrollback(); @@ -1418,6 +1476,28 @@ impl App { .unwrap_or(0) } + /// Status-panel height with shrink hysteresis. Growing applies + /// immediately; shrinking only after the smaller height has been desired + /// for a few seconds. Without this, a session whose inferred state flaps + /// (codex repaints re-triggering WAITING↔RUNNING) adds and removes its + /// waiting-preview row every few hundred ms; each change resizes the + /// output panes, the resized TUI repaints, the repaint re-flaps the + /// Where the status panel is configured to live. + pub fn status_placement(&self) -> StatusPlacement { + StatusPlacement::parse(&self.config.general.status_panel) + } + + /// Whether the panel occupies a region of the layout right now — docked + /// *and* not hidden. Callers use this to decide whether to reserve rows + /// or columns for it. + pub fn status_docked(&self) -> bool { + !self.status_hidden + && matches!( + self.status_placement(), + StatusPlacement::Left | StatusPlacement::Bottom + ) + } + /// Status-panel height with shrink hysteresis. Growing applies /// immediately; shrinking only after the smaller height has been desired /// for a few seconds. Without this, a session whose inferred state flaps @@ -3045,6 +3125,21 @@ impl App { self.mode = AppMode::Normal; return; } + AppMode::Status => { + // Rows stay clickable inside the overlay; clicking one + // means "take me there", so it also closes. + for (i, row_area) in self.status_row_areas.iter().enumerate() { + if rect_hit(*row_area, col, row) { + if let Some(idx) = self.visible_to_idx(i) { + self.switch_to(idx); + } + break; + } + } + self.mode = AppMode::Normal; + self.selection = None; + return; + } AppMode::Search { .. } => { self.mode = AppMode::Normal; return; @@ -3338,13 +3433,13 @@ impl App { let session = self.active_session()?; let screen = session.screen.screen(); let (screen_rows, screen_cols) = screen.size(); - let display_rows = self - .output_areas - .get(self.focused_pane) - .copied() - .unwrap_or_default() - .height - .saturating_sub(2); + let display_rows = crate::ui::pane_content_area( + self.output_areas + .get(self.focused_pane) + .copied() + .unwrap_or_default(), + ) + .height; let start_vt_row = screen_rows.saturating_sub(display_rows); let ((min_row, min_col), (max_row, max_col)) = sel.normalized(); @@ -4596,19 +4691,25 @@ impl App { return; } let cur = selected_sub.unwrap_or(0) as i32; - // Separators are not landable; step past them in the direction of - // travel so arrow keys never park on a divider. + // Step past anything that cannot be landed on, in the direction of + // travel. Disabled rows count: the renderer draws them dim and + // never highlights them, so parking on one loses the cursor + // entirely — you cannot see where you are or act on it. let step = if delta >= 0 { 1 } else { -1 }; let mut next = (cur + delta).rem_euclid(count); + let mut found = false; for _ in 0..count { - if section.items[next as usize].action != MenuAction::Separator { + if section.items[next as usize].is_selectable() { + found = true; break; } next = (next + step).rem_euclid(count); } self.mode = AppMode::Menu { selected_top, - selected_sub: Some(next as usize), + // A section with nothing selectable leaves focus on the menu + // bar rather than on an invisible selection. + selected_sub: found.then_some(next as usize), }; } } @@ -4624,6 +4725,19 @@ impl App { } } + /// Index of the first row in the open section that can be landed on. + /// `Up` pops back to the menu bar here rather than wrapping to the + /// bottom, which is what "the top of the list" means to someone holding + /// the key down — the literal index 0 may be a separator or disabled. + pub fn menu_first_selectable(&self) -> Option { + if let AppMode::Menu { selected_top, .. } = self.mode { + let sections = self.menu(); + let section = sections.get(selected_top)?; + return section.items.iter().position(|i| i.is_selectable()); + } + None + } + pub fn menu_close_submenu(&mut self) { if let AppMode::Menu { selected_top, .. } = self.mode { self.mode = AppMode::Menu { @@ -5248,6 +5362,13 @@ impl MenuItem { self } + /// Whether arrow keys may land on this row. Separators are structure and + /// disabled rows cannot be acted on; both are rendered without a + /// highlight, so selecting one makes the cursor vanish. + pub fn is_selectable(&self) -> bool { + self.enabled && self.action != MenuAction::Separator + } + /// Rendered width needed for label plus detail. pub fn width(&self) -> usize { if self.detail.is_empty() { @@ -5267,6 +5388,24 @@ pub struct MenuSection { // ── Chat ────────────────────────────────────────────────────────────────────── impl App { + /// alt-s. For a docked panel this hides and shows it in place; for the + /// overlay placement it opens and closes the overlay. `off` means off. + pub fn toggle_status_panel(&mut self) { + match self.status_placement() { + StatusPlacement::Left | StatusPlacement::Bottom => { + self.status_hidden = !self.status_hidden; + } + StatusPlacement::Overlay => { + self.mode = if matches!(self.mode, AppMode::Status) { + AppMode::Normal + } else { + AppMode::Status + }; + } + StatusPlacement::Off => {} + } + } + pub fn toggle_chat(&mut self) { if self.chat_docked.is_some() { self.undock_chat(); @@ -7496,12 +7635,16 @@ fn byte_index_for_col(text: &str, col: usize) -> usize { /// Convert absolute terminal coords to (content_col, content_row) inside a bordered rect. fn to_content_coords(area: Rect, col: u16, row: u16) -> (u16, u16) { + // Must agree with the renderer and the PTY size, or a drag-selection maps + // onto the wrong vt100 columns; `pane_content_area` is where that + // agreement lives. + let content = crate::ui::pane_content_area(area); let c = col - .saturating_sub(area.x + 1) - .min(area.width.saturating_sub(2)); + .saturating_sub(content.x) + .min(content.width.saturating_sub(1)); let r = row - .saturating_sub(area.y + 1) - .min(area.height.saturating_sub(2)); + .saturating_sub(content.y) + .min(content.height.saturating_sub(1)); (c, r) } @@ -7605,6 +7748,95 @@ mod tests { (si, ii, sections[si].items[ii].clone()) } + /// Walking a section with the arrow keys must never leave the cursor + /// nowhere. The renderer draws separators and disabled rows without a + /// highlight, so landing on one loses the selection visually: you cannot + /// see where you are, and Enter does nothing. + #[test] + fn arrowing_through_a_menu_never_lands_on_an_unselectable_row() { + let mut app = make_app(); + app.open_menu(); + let sections = app.menu(); + for (si, section) in sections.iter().enumerate() { + if !section + .items + .iter() + .any(|i| i.enabled && i.action != MenuAction::Separator) + { + continue; + } + app.mode = AppMode::Menu { + selected_top: si, + selected_sub: None, + }; + app.menu_open_submenu(); + // Two full laps, so wrapping is covered in both directions. + for delta in [1, -1] { + for _ in 0..section.items.len() * 2 { + let AppMode::Menu { selected_sub, .. } = app.mode else { + panic!("left menu mode"); + }; + let idx = selected_sub + .unwrap_or_else(|| panic!("no selection in section '{}'", section.title)); + let row = §ion.items[idx]; + assert!( + row.enabled && row.action != MenuAction::Separator, + "section '{}' parked on an unselectable row {} ({:?}, enabled={})", + section.title, + idx, + row.label, + row.enabled + ); + app.menu_move_sub(delta); + } + } + } + } + + /// The exact report: the Orchestrator section's "Show/Hide Session" is + /// disabled when no CLI-class orchestrator session exists, and arrowing + /// down onto it made the cursor disappear. + #[test] + fn a_disabled_orchestrator_row_is_stepped_over() { + let mut app = make_app(); + assert!(app.orchestrator_session_id.is_none()); + let (si, disabled_idx, item) = find_item(&app, "Orchestrator", "Show/Hide Session"); + assert!(!item.enabled, "precondition: the row is disabled"); + + app.mode = AppMode::Menu { + selected_top: si, + selected_sub: Some(disabled_idx - 1), + }; + app.menu_move_sub(1); + let AppMode::Menu { selected_sub, .. } = app.mode else { + panic!("left menu mode") + }; + let landed = selected_sub.expect("cursor vanished"); + assert_ne!(landed, disabled_idx, "stopped on the disabled row"); + let sections = app.menu(); + assert!( + sections[si].items[landed].enabled, + "landed on a disabled row: {:?}", + sections[si].items[landed].label + ); + } + + /// Up at the top of a section pops back to the menu bar. The topmost + /// selectable row is not always index 0, so keying on the literal 0 + /// wrapped to the bottom instead. + #[test] + fn up_from_the_first_selectable_row_returns_to_the_menu_bar() { + let mut app = make_app(); + app.open_menu(); + app.menu_open_submenu(); + let first = app.menu_first_selectable(); + assert!(first.is_some()); + let AppMode::Menu { selected_sub, .. } = app.mode else { + panic!() + }; + assert_eq!(selected_sub, first, "opening lands on the first selectable"); + } + #[test] fn menu_section_mnemonics_are_unique() { let app = make_app(); @@ -9432,8 +9664,10 @@ mod tests { assert!(!rect_hit(rect, 30, 5)); assert!(!rect_inner_hit(rect, 10, 5)); assert!(rect_inner_hit(rect, 11, 6)); - assert_eq!(to_content_coords(rect, 12, 8), (1, 2)); - assert_eq!(to_content_coords(rect, 99, 99), (18, 8)); + // Content starts at column x+2 (focus bar, then padding) and row y+1 + // (the title line) — see ui::pane_content_area. + assert_eq!(to_content_coords(rect, 12, 8), (0, 2)); + assert_eq!(to_content_coords(rect, 99, 99), (17, 8)); } #[test] @@ -9561,18 +9795,46 @@ mod tests { app.scroll_up(20); assert_eq!(app.sessions[0].history_scroll, 20); assert_eq!(app.scroll_offset(), 20); - // …is clamped to what exists… + // …is clamped so the oldest *page* is the end of the road, not the + // oldest line: scrolling past that moves the indicator without moving + // the view, which reads as the scrollback having stopped working. app.scroll_up(500); - assert_eq!(app.sessions[0].history_scroll, 100); + let page = app.pane_sizes[app.focused_pane].0 as usize; + assert_eq!(app.sessions[0].history_scroll, 100 - page); // …new output does NOT yank the view back… app.handle_session_bytes(id, b"more output\r\n".to_vec()); - assert_eq!(app.sessions[0].history_scroll, 100); + assert_eq!(app.sessions[0].history_scroll, 100 - page); // …and typing returns to the live tail. app.write_to_active(b"x"); assert_eq!(app.sessions[0].history_scroll, 0); assert_eq!(app.scroll_offset(), 0); } + /// The clamp must be against the buffer the view renders. For an agent + /// TUI `output_lines` is the raw PTY line stream — hundreds of repaint + /// fragments — and clamping to that let the offset run far past the + /// captured transcript, into a window with nothing in it. + #[tokio::test] + async fn scroll_is_clamped_to_the_captured_transcript_not_the_line_stream() { + let mut app = make_app(); + let id = app.spawn_headless_session("cx".to_string(), None).unwrap(); + app.panes[0] = app.sessions.iter().position(|s| s.id == id); + { + let s = app.sessions.iter_mut().find(|s| s.id == id).unwrap(); + s.kind = crate::session::SessionKind::Codex; + // A lot of repaint noise, a little real transcript. + for i in 0..500 { + s.push_output_line(format!("repaint-{i}")); + } + for i in 0..60 { + s.push_scrollback_line(format!("line-{i}")); + } + } + let page = app.pane_sizes[app.focused_pane].0 as usize; + app.scroll_up(10_000); + assert_eq!(app.sessions[0].history_scroll, 60usize.saturating_sub(page)); + } + fn orch_request( app: &mut App, req: crate::events::OrchestratorReq, diff --git a/src/config.rs b/src/config.rs index 11338b4..4aab3c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ pub struct Config { /// # api_key = "..." # optional; sent as Bearer if set pub agents: HashMap, pub general: GeneralConfig, + pub theme: ThemeConfig, pub socket: SocketConfig, pub sessions: SessionsConfig, pub pipe: PipeConfig, @@ -742,6 +743,45 @@ fn default_extract() -> String { "last_block".into() } +// ── [theme] ─────────────────────────────────────────────────────────────── + +/// Palette selection and per-field overrides: +/// +/// [theme] +/// base = "dark" # "classic" | "dark" | "ansi16" +/// accent = "#5fb3d4" # any field overridable as #rrggbb +/// +/// `base` unset auto-detects from `COLORTERM`. See `theme.rs`. +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct ThemeConfig { + pub base: Option, + pub bg: Option, + pub surface: Option, + pub chrome: Option, + pub text: Option, + pub text_dim: Option, + pub text_bright: Option, + pub accent: Option, + pub warn: Option, + pub err: Option, + pub ok: Option, + pub info: Option, + pub ctx: Option, + pub cost: Option, + pub pipe: Option, + pub on_accent: Option, + pub sel_bg: Option, + pub kind_claude: Option, + pub kind_codex: Option, + pub kind_opencode: Option, + pub kind_ohmypi: Option, + pub kind_aider: Option, + pub kind_shell: Option, + pub kind_custom: Option, + pub kind_orch: Option, +} + // ── [general] ───────────────────────────────────────────────────────────── #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] @@ -752,6 +792,21 @@ pub struct GeneralConfig { pub tick_interval_ms: u64, pub ipc_state_override_timeout_secs: u64, pub menu_key: String, + /// Where the status panel lives: + /// + /// - "left" (default) — a permanent sidebar beside the output. Costs + /// columns instead of rows, and unlike the bottom region it has the + /// full terminal height, so it doesn't run out of room at 4 sessions. + /// - "bottom" — the always-on region below the output. + /// - "overlay" — not docked; alt-s opens it centered over the output. + /// - "off" — never shown. + /// + /// In "left" and "bottom", alt-s hides and shows the panel at runtime. + pub status_panel: String, + /// Columns claimed by the left sidebar. Below `status_panel_width` + + /// 60 columns of terminal the sidebar collapses to a rail (see + /// `ui::SIDEBAR_RAIL_COLS`) rather than starving the output pane. + pub status_panel_width: u16, } impl Default for GeneralConfig { @@ -762,6 +817,8 @@ impl Default for GeneralConfig { tick_interval_ms: 100, ipc_state_override_timeout_secs: 60, menu_key: "ctrl+space".to_string(), + status_panel: "left".to_string(), + status_panel_width: 28, } } } diff --git a/src/doctor.rs b/src/doctor.rs index 46fe6cb..a6bb929 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -29,6 +29,10 @@ struct Context { term: Option, colors: Option, nested_mux: Option<&'static str>, + /// Raw COLORTERM — what decides whether the truecolor theme is usable. + colorterm: Option, + /// `[theme] base`, when the config pins one. + theme_base: Option, } pub fn run() -> i32 { @@ -42,6 +46,8 @@ pub fn run() -> i32 { path: std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect(), term: std::env::var("TERM").ok(), colors: terminal_colors(), + colorterm: std::env::var("COLORTERM").ok().filter(|v| !v.is_empty()), + theme_base: config_theme_base(), nested_mux: if std::env::var_os("TMUX").is_some() { Some("tmux") } else if std::env::var_os("STY").is_some() { @@ -53,6 +59,14 @@ pub fn run() -> i32 { run_with(&context, &mut std::io::stdout()) } +/// `[theme] base`, if the user pinned one. A broken config is not this +/// check's problem — "config validity" reports that. +fn config_theme_base() -> Option { + let path = crate::config::config_path()?; + let cfg = crate::config::load_strict(&path).ok()?; + cfg.theme.base.filter(|b| !b.trim().is_empty()) +} + fn run_with(context: &Context, out: &mut dyn Write) -> i32 { let mut failures = 0; let mut report = |name: &str, level: Level, detail: String| { @@ -151,6 +165,31 @@ fn run_with(context: &Context, out: &mut dyn Write) -> i32 { } } + // "Is it my terminal or the layout?" — the theme that actually resolved + // is the answer, and it is not otherwise visible from inside the TUI. + match context.theme_base.as_deref() { + Some(base) => report( + "theme", + Level::Ok, + format!("[theme] base = \"{base}\" (pinned in config)"), + ), + None if context.colorterm.is_some() => report( + "theme", + Level::Ok, + format!( + "COLORTERM={}; using the truecolor 'classic' theme", + context.colorterm.as_deref().unwrap_or("") + ), + ), + None => report( + "theme", + Level::Warn, + "COLORTERM unset, so truecolor is assumed absent; using the \ + 'ansi16' theme. Set COLORTERM=truecolor or pin [theme] base." + .into(), + ), + } + match context.config.as_deref() { Some(path) if path.exists() => match crate::config::load_strict(path) { Ok(_) => report( @@ -288,6 +327,8 @@ mod tests { term: Some("xterm-256color".into()), colors: Some(256), nested_mux: None, + colorterm: Some("truecolor".into()), + theme_base: None, } } @@ -305,6 +346,7 @@ mod tests { "codex JSONL logs", "socket dir", "terminal", + "theme", "config validity", ] { assert!(output.contains(check), "missing {check} in {output}"); @@ -312,6 +354,29 @@ mod tests { assert!(!output.contains("fail ")); } + #[test] + fn missing_colorterm_warns_about_the_ansi16_fallback() { + let temp = tempfile_dir("no-colorterm"); + let mut context = fixture(&temp); + context.colorterm = None; + let mut output = Vec::new(); + run_with(&context, &mut output); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("ansi16"), "{output}"); + } + + #[test] + fn a_pinned_theme_base_is_reported_verbatim() { + let temp = tempfile_dir("pinned-theme"); + let mut context = fixture(&temp); + context.theme_base = Some("dark".into()); + context.colorterm = None; + let mut output = Vec::new(); + run_with(&context, &mut output); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("base = \"dark\""), "{output}"); + } + #[test] fn warnings_do_not_fail_but_invalid_config_does() { let temp = tempfile_dir("invalid"); diff --git a/src/keybindings.rs b/src/keybindings.rs index 99a06a9..5889b88 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -18,6 +18,7 @@ pub enum Action { ScrollDownLine, OpenMenu, ToggleChat, + ToggleStatus, DockChat, SplitPaneRight, SplitPaneDown, @@ -69,6 +70,7 @@ fn default_keymap() -> Keymap { m.insert((ctrl, KeyCode::Char('q')), Action::Quit); m.insert((ctrl, KeyCode::Char(' ')), Action::OpenMenu); m.insert((alt, KeyCode::Char('t')), Action::ToggleChat); + m.insert((alt, KeyCode::Char('s')), Action::ToggleStatus); m.insert((alt, KeyCode::Char('g')), Action::DockChat); m.insert((alt, KeyCode::Char('\\')), Action::SplitPaneRight); m.insert((alt, KeyCode::Char('-')), Action::SplitPaneDown); @@ -84,10 +86,11 @@ fn default_keymap() -> Keymap { m.insert((alt, KeyCode::BackTab), Action::PrevSession); let alt_shift = KeyModifiers::ALT | KeyModifiers::SHIFT; // Shifted alt-p: the same pane, taking the whole terminal. Registered - // under both modifier sets because whether SHIFT is reported alongside an - // already-uppercase char depends on the terminal's keyboard protocol — - // kitty-style reporting sets it, a plain ESC-prefixed 'P' does not. + // under all observed modifier/character combinations because terminals + // disagree on whether SHIFT is reported separately and whether the + // character itself is uppercased. m.insert((alt_shift, KeyCode::Char('P')), Action::PlanningFullscreen); + m.insert((alt_shift, KeyCode::Char('p')), Action::PlanningFullscreen); m.insert((alt, KeyCode::Char('P')), Action::PlanningFullscreen); m.insert((alt_shift, KeyCode::PageUp), Action::ScrollUpPage); m.insert((alt_shift, KeyCode::PageDown), Action::ScrollDownPage); @@ -174,6 +177,7 @@ fn parse_action(s: &str) -> Option { "scroll_up_line" => Some(Action::ScrollUpLine), "scroll_down_line" => Some(Action::ScrollDownLine), "toggle_chat" | "chat" => Some(Action::ToggleChat), + "toggle_status" | "status" => Some(Action::ToggleStatus), "dock_chat" | "chat_dock" => Some(Action::DockChat), "dock_planning" | "planning" => Some(Action::DockPlanning), "planning_fullscreen" => Some(Action::PlanningFullscreen), @@ -223,6 +227,26 @@ mod tests { ); } + #[test] + fn planning_fullscreen_accepts_terminal_shift_variants() { + let map = build_keymap(&KeybindingsConfig::default()); + let alt = KeyModifiers::ALT; + let alt_shift = KeyModifiers::ALT | KeyModifiers::SHIFT; + + assert_eq!( + map.get(&(alt_shift, KeyCode::Char('P'))), + Some(&Action::PlanningFullscreen) + ); + assert_eq!( + map.get(&(alt_shift, KeyCode::Char('p'))), + Some(&Action::PlanningFullscreen) + ); + assert_eq!( + map.get(&(alt, KeyCode::Char('P'))), + Some(&Action::PlanningFullscreen) + ); + } + #[test] fn custom_bindings_resolve_vars_and_override_defaults() { let mut cfg = KeybindingsConfig::default(); diff --git a/src/main.rs b/src/main.rs index 9794df5..50051c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod planning; mod protocol; mod reattach; mod session; +mod theme; mod ui; use std::sync::{Arc, Mutex}; @@ -428,10 +429,10 @@ async fn run_server() -> anyhow::Result<()> { if idx >= sizes.len() { break; } - sizes[idx] = ( - area.height.saturating_sub(2).max(1), - area.width.saturating_sub(2).max(1), - ); + // One definition of the pane's inner geometry, shared with + // the renderer and the mouse→vt100 column mapping. + let content = ui::pane_content_area(*area); + sizes[idx] = (content.height.max(1), content.width.max(1)); } let old_sizes = app.pane_sizes.clone(); app.handle_pane_resize(&sizes); @@ -862,6 +863,7 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { Action::ScrollDownLine => app.scroll_down(3), Action::OpenMenu => app.open_menu(), Action::ToggleChat => app.toggle_chat(), + Action::ToggleStatus => app.toggle_status_panel(), Action::DockChat => { if app.chat_docked.is_some() { app.undock_chat(); @@ -911,15 +913,15 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.planning_key(key); return; } - // Full-screen agent TUIs (claude, codex) ignore the terminal's + // Agent TUIs (claude, codex) ignore the terminal's // PageUp/PageDown sequences, so route those keys to linkshell's // captured scrollback — the same history the mouse wheel scrolls. // Shells and other alt-screen apps (vim, less) still get the keys. if key.modifiers.is_empty() && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) { - let agent_alt_screen = app.active_session().is_some_and(|s| { - s.kind.captures_alt_scrollback() && s.screen.screen().alternate_screen() - }); - if agent_alt_screen { + let agent_tui = app + .active_session() + .is_some_and(|s| s.kind.captures_scrollback()); + if agent_tui { if key.code == KeyCode::PageUp { app.scroll_up(20); } else { @@ -1079,6 +1081,11 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.chat_key(key); } + AppMode::Status => match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.mode = AppMode::Normal, + _ => {} + }, + AppMode::PipeList => match key.code { KeyCode::Esc | KeyCode::Char('q') => app.mode = AppMode::Normal, KeyCode::Up => app.pipe_list_move(-1), @@ -1133,10 +1140,15 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.menu_open_submenu() } } - KeyCode::Up => match selected_sub { - Some(0) | None => app.menu_close_submenu(), - Some(_) => app.menu_move_sub(-1), - }, + KeyCode::Up => { + // Pop back to the menu bar from the topmost row that can + // actually be selected, which is not always index 0. + if selected_sub.is_none() || selected_sub == app.menu_first_selectable() { + app.menu_close_submenu(); + } else { + app.menu_move_sub(-1); + } + } KeyCode::Enter => app.execute_selected_menu_action(), KeyCode::Esc => app.mode = AppMode::Normal, // Mnemonics come from the live section titles, so adding or diff --git a/src/session.rs b/src/session.rs index eff0b02..c0d4f60 100644 --- a/src/session.rs +++ b/src/session.rs @@ -92,9 +92,17 @@ impl SessionKind { .unwrap_or(false) } - /// True for TUI-based agent sessions whose transcript is useful as scrollback. - /// Full-repaint dashboards (htop, btop) would spew garbage if enabled here. - pub fn captures_alt_scrollback(&self) -> bool { + /// True for TUI-based agent sessions whose transcript is useful as + /// scrollback, and which therefore get our own capture rather than + /// vt100's. Full-repaint dashboards (htop, btop) would spew garbage if + /// enabled here. + /// + /// Two different reasons these sessions have no usable vt100 scrollback: + /// claude lives on the alternate screen, where there is none by design; + /// codex stays on the normal screen but scrolls inside a DECSTBM region, + /// and vt100 (correctly, per the DEC spec) discards lines evicted from a + /// restricted region instead of pushing them to scrollback. + pub fn captures_scrollback(&self) -> bool { matches!(self, SessionKind::Claude | SessionKind::Codex) } @@ -111,6 +119,157 @@ impl SessionKind { } } +/// Split a chunk after every DECSTBM (`ESC [ params r`) sequence. +/// +/// Each item is a slice to feed vt100 and, when that slice ended with a +/// DECSTBM, its parameters — so the caller can mirror the region change vt100 +/// applies but does not expose. The escape stays inside the slice; only the +/// caller's copy of the region is updated afterwards. +fn decstbm_segments(data: &[u8]) -> Vec<(&[u8], Option>)> { + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + while i + 1 < data.len() { + if data[i] != 0x1b || data[i + 1] != b'[' { + i += 1; + continue; + } + let mut j = i + 2; + while j < data.len() && (data[j].is_ascii_digit() || data[j] == b';') { + j += 1; + } + if j < data.len() && data[j] == b'r' { + out.push((&data[start..=j], Some(data[i + 2..j].to_vec()))); + start = j + 1; + i = j + 1; + } else { + i = j.max(i + 2); + } + } + if start < data.len() || out.is_empty() { + out.push((&data[start..], None)); + } + out +} + +/// Upper bound on how finely one segment is split for capture. A burst with +/// more newlines than this has already replaced everything on screen several +/// times over, so the older lines are unrecoverable anyway and there is +/// nothing to buy by rendering the region for each one. +const MAX_SCROLL_PIECES: usize = 512; + +/// Split a segment so each piece performs at most one scroll: after every +/// newline, which is what scrolls a region in practice. +fn scroll_pieces(segment: &[u8]) -> Vec<&[u8]> { + let mut out: Vec<&[u8]> = Vec::new(); + let mut start = 0; + for (i, b) in segment.iter().enumerate() { + if *b == b'\n' { + out.push(&segment[start..=i]); + start = i + 1; + if out.len() >= MAX_SCROLL_PIECES { + break; + } + } + } + if start < segment.len() { + out.push(&segment[start..]); + } + if out.is_empty() { + out.push(segment); + } + out +} + +/// Whether a slice could possibly scroll the screen. Snapshotting the region +/// costs a row render, and the overwhelming majority of a repainting TUI's +/// output is cursor positioning that moves nothing. +fn may_scroll(segment: &[u8]) -> bool { + segment.contains(&b'\n') + || segment.contains(&0x0b) + || segment.contains(&0x0c) + || segment + .windows(2) + .any(|w| w == b"\x1bD" || w == b"\x1bM" || w == b"\x1bE") + || find_final(segment, b'S') + || find_final(segment, b'T') +} + +/// True when the slice contains a CSI sequence with the given final byte. +fn find_final(data: &[u8], final_byte: u8) -> bool { + let mut i = 0; + while i + 1 < data.len() { + if data[i] != 0x1b || data[i + 1] != b'[' { + i += 1; + continue; + } + let mut j = i + 2; + while j < data.len() && (data[j].is_ascii_digit() || data[j] == b';') { + j += 1; + } + if j < data.len() && data[j] == final_byte { + return true; + } + i = j.max(i + 2); + } + false +} + +/// Parse DECSTBM parameters (`top;bottom`, 1-based inclusive) into a 0-based +/// inclusive row range. An empty or degenerate region means "the whole +/// screen", which is what resets it. +fn parse_decstbm(params: &[u8], rows: u16) -> Option<(u16, u16)> { + let text = std::str::from_utf8(params).ok()?; + let mut parts = text.split(';'); + let top: u16 = parts.next().unwrap_or("").trim().parse().unwrap_or(1); + let bottom: u16 = parts + .next() + .unwrap_or("") + .trim() + .parse() + .unwrap_or(rows.max(1)); + let top = top.max(1) - 1; + let bottom = bottom.max(1).min(rows.max(1)) - 1; + if top >= bottom { + return None; + } + Some((top, bottom)) +} + +/// How many lines scrolled off the top of the region between two snapshots: +/// where the new top row used to sit in the old ones. +/// +/// Anchoring on the top row rather than matching the whole overlap is what +/// makes this work on a live screen. The obvious formulation — the smallest +/// `k` with `prev[k..] == cur[..n-k]` — never matches in practice, because +/// the same chunk that scrolled also wrote new content into the bottom rows, +/// so the two slices always disagree at the tail. +/// +/// A blank new top row is treated as "no scroll": it identifies nothing, and +/// blank lines are not worth reconstructing history from. The row below is +/// checked as corroboration so a screen that merely repeats a line does not +/// read as having scrolled to it. +fn scrolled_off(prev: &[String], cur: &[String]) -> usize { + let n = prev.len(); + if n == 0 || cur.is_empty() { + return 0; + } + let head = cur[0].trim(); + if head.is_empty() { + return 0; + } + for k in 1..n { + if prev[k].trim() != head { + continue; + } + if k + 1 < n && cur.len() >= 2 && prev[k + 1] != cur[1] { + continue; + } + return k; + } + 0 +} + /// True when a command basename is `claude` or a claude wrapper following the /// common naming convention: `claude-work`, `claude.sh`, `claude_glm`. Wrappers /// with unrelated names still need a [sessions.aliases] entry. @@ -282,16 +441,29 @@ pub struct Session { pub pty_writer: Option>>, /// Send resize events to the PTY writer task pub pty_resizer: Option>, - /// Scrollback of stripped output lines for pipe extraction + /// Stripped output lines as they arrive from the PTY reader, for pattern + /// matching, pipe extraction and `read`. For a repainting TUI this is + /// mostly repaint fragments — which is fine for those consumers and + /// useless as scrollback, hence the separate buffer below. pub output_lines: VecDeque, + /// The transcript recovered from the screen as it scrolls: what the + /// scrollback view walks. Kept apart from `output_lines` because that one + /// is a stream of whatever crossed the PTY, and for claude and codex the + /// two have almost nothing to do with each other. + pub scrollback_lines: VecDeque, pub scroll_buffer_lines: usize, /// Raw bytes received since the last tick — used to detect active generation /// without relying on newlines (Claude Code streams via cursor movement, not \n). pub bytes_since_last_tick: usize, - /// Scroll offset (in lines) into `output_lines` history. Used when the - /// application occupies the alternate screen (full-screen TUIs), where - /// vt100 keeps no scrollback; unifies scrolling across session types. + /// Scroll offset (in lines) into `output_lines` history. Used for + /// sessions whose scrollback we capture ourselves because vt100 has none + /// to offer; unifies scrolling across session types. pub history_scroll: usize, + /// The DECSTBM scrolling region (top, bottom), inclusive and 0-based. + /// vt100 tracks this internally but does not expose it, and we need it: + /// only rows inside the region move when the app scrolls, so it is the + /// only slice of the screen worth diffing for evicted lines. + scroll_region: (u16, u16), /// Resolved CLI identity: which JSONL watcher / stats pipeline applies. /// Defaults from the command's base name; spawn_session refines it with /// the config alias table. @@ -380,9 +552,11 @@ impl Session { pty_writer: None, pty_resizer: None, output_lines: VecDeque::new(), + scrollback_lines: VecDeque::new(), scroll_buffer_lines, bytes_since_last_tick: 0, history_scroll: 0, + scroll_region: (0, rows.saturating_sub(1)), base, log_path: None, stats_from_watcher: false, @@ -399,39 +573,13 @@ impl Session { pub fn process_bytes(&mut self, data: &[u8], detect_change: bool) -> bool { use std::hash::{Hash, Hasher}; - // Capture top-line snapshot BEFORE processing, if this session kind - // supports alt-screen scrollback capture and we're on the alternate screen. - let prev_top = - if self.kind.captures_alt_scrollback() && self.screen.screen().alternate_screen() { - self.top_row() - } else { - None - }; - self.bytes_since_last_tick += data.len(); - self.screen.process(data); - - // After processing, check if content scrolled (top row changed) - if let Some(ref prev) = prev_top { - let current_top: Option = self.top_row(); - - // If the top line changed, content scrolled upward. The old top row - // was pushed off-screen — capture it for scrollback. - if let Some(cur) = current_top { - if cur != *prev && !prev.is_empty() { - // Dedupe: skip if identical to last appended line - let dominated = self.output_lines.back().is_some_and(|last| *last == *prev); - if !dominated { - self.push_output_line(prev.clone()); - } - } - } else { - // Screen is now empty (left alternate screen?), capture the old top - if !prev.is_empty() && self.output_lines.back().is_none_or(|last| *last != *prev) { - self.push_output_line(prev.clone()); - } - } + if self.kind.captures_scrollback() { + self.process_capturing(data); + } else { + self.screen.process(data); } + if !detect_change { // Leave last_screen_hash stale on purpose: the first chunk after the // session becomes visible again then reads as changed and redraws. @@ -447,22 +595,123 @@ impl Session { changed } - /// Text of the screen's top row. `Screen::rows` is a lazy iterator, so this - /// renders one row — unlike `contents()`, which builds the entire screen - /// into a String and was being called twice per PTY chunk. - fn top_row(&self) -> Option { - let cols = self.screen.screen().size().1; - self.screen - .screen() + /// Process a chunk while recovering the lines it scrolls away. + /// + /// Two levels of splitting, each for its own reason. + /// + /// The chunk is split at every DECSTBM sequence so each piece is handled + /// under one stable scrolling region. Without that, the region can change + /// mid-chunk — codex sets `ESC[1;25r`, scrolls, then resets with `ESC[r`, + /// dozens of times a second — and a diff taken across the change compares + /// the transcript against the composer codex pins below it, which reads + /// as a scroll and captures the composer into the history. + /// + /// Within a piece that can scroll, it is split again at newlines, because + /// a single before/after snapshot only shows the net movement. A chunk + /// carrying six newlines scrolls six times, and the lines evicted by the + /// first five are gone by the time we look. + fn process_capturing(&mut self, data: &[u8]) { + for (segment, region) in decstbm_segments(data) { + if self.vt100_keeps_no_scrollback() && may_scroll(segment) { + for piece in scroll_pieces(segment) { + let prev = self.region_rows(); + self.screen.process(piece); + self.capture_evicted(&prev); + } + } else { + self.screen.process(segment); + } + + // vt100 has now applied the sequence; mirror it, since vt100 + // tracks the region internally but does not expose it. + if let Some(params) = region { + let rows = self.screen.screen().size().0; + self.scroll_region = + parse_decstbm(¶ms, rows).unwrap_or((0, rows.saturating_sub(1))); + } + } + } + + /// Append whatever scrolled off the top of the region since `prev`. + fn capture_evicted(&mut self, prev: &[String]) { + let cur = self.region_rows(); + let shift = scrolled_off(prev, &cur); + for line in prev.iter().take(shift) { + let line = line.trim_end(); + if line.is_empty() { + continue; + } + if self + .scrollback_lines + .back() + .is_some_and(|last| last == line) + { + continue; + } + self.push_scrollback_line(line.to_string()); + } + } + + /// True when vt100 will not retain what scrolls away, so recovering it is + /// on us. Two independent reasons, one per agent CLI we support: + /// + /// - The alternate screen's grid is built as `Grid::new(size, 0)` — zero + /// scrollback, by construction. This is claude. + /// - A restricted DECSTBM region: `Grid::scroll_up` only pushes to + /// scrollback `if !self.scroll_region_active()`, which is correct per + /// the DEC spec. This is codex, which stays on the normal screen and so + /// looked like an ordinary app to every check we had. + fn vt100_keeps_no_scrollback(&self) -> bool { + self.screen.screen().alternate_screen() || self.scroll_region_restricted() + } + + /// True when the app has reserved part of the screen for itself. + fn scroll_region_restricted(&self) -> bool { + let rows = self.screen.screen().size().0; + let (top, bottom) = self.scroll_region; + top > 0 || bottom + 1 < rows + } + + /// The scrolling region's rows as plain text. + fn region_rows(&self) -> Vec { + let screen = self.screen.screen(); + let (rows, cols) = screen.size(); + let (top, bottom) = self.scroll_region; + if top >= rows { + return Vec::new(); + } + let count = u32::from(bottom.min(rows - 1) - top) + 1; + screen .rows(0, cols) - .next() - .map(|s| s.trim_end().trim_end_matches('\t').to_string()) + .skip(usize::from(top)) + .take(count as usize) + .map(|r| r.trim_end().to_string()) + .collect() } pub fn resize_screen(&mut self, rows: u16, cols: u16) { self.screen.set_size(rows, cols); } + /// The lines the scrollback view scrolls through. Sessions we capture for + /// use the recovered transcript; everything else falls back to the raw + /// line stream, which is what an alt-screen shell (vim, less) scrolls + /// back into. + pub fn history_lines(&self) -> &VecDeque { + if self.kind.captures_scrollback() { + &self.scrollback_lines + } else { + &self.output_lines + } + } + + pub fn push_scrollback_line(&mut self, line: String) { + self.scrollback_lines.push_back(line); + if self.scrollback_lines.len() > self.scroll_buffer_lines { + self.scrollback_lines.pop_front(); + } + } + pub fn push_output_line(&mut self, line: String) { self.last_output_at = Some(Instant::now()); self.output_lines.push_back(line); @@ -752,6 +1001,198 @@ pub fn extract_waiting_prompt(lines: &VecDeque) -> Option { mod tests { use super::*; + // ── Scrollback capture ──────────────────────────────────────────────── + + fn tui(kind: SessionKind, rows: u16, cols: u16) -> Session { + Session::new(0, "s".into(), kind, "/tmp".into(), rows, cols, 1000) + } + + fn history(s: &Session) -> Vec { + s.scrollback_lines.iter().cloned().collect() + } + + #[test] + fn decstbm_params_map_to_zero_based_inclusive_rows() { + assert_eq!(parse_decstbm(b"1;25", 30), Some((0, 24))); + assert_eq!(parse_decstbm(b"8;30", 30), Some((7, 29))); + // Empty params mean the whole screen. + assert_eq!(parse_decstbm(b"", 30), Some((0, 29))); + // Degenerate regions reset rather than invert. + assert_eq!(parse_decstbm(b"10;10", 30), None); + assert_eq!(parse_decstbm(b"20;5", 30), None); + // A bottom past the screen clamps. + assert_eq!(parse_decstbm(b"1;99", 30), Some((0, 29))); + } + + #[test] + fn scrolled_off_locates_the_new_top_row_in_the_old_ones() { + let rows = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + let prev = rows(&["a", "b", "c", "d"]); + assert_eq!(scrolled_off(&prev, &rows(&["a", "b", "c", "d"])), 0); + assert_eq!(scrolled_off(&prev, &rows(&["b", "c", "d", "e"])), 1); + assert_eq!(scrolled_off(&prev, &rows(&["c", "d", "e", "f"])), 2); + // A full repaint is not a scroll, and must not be mistaken for one. + assert_eq!(scrolled_off(&prev, &rows(&["w", "x", "y", "z"])), 0); + // The tail may already hold new content written by the same chunk — + // the case exact suffix matching gets wrong. + assert_eq!(scrolled_off(&prev, &rows(&["c", "d", "NEW", "NEWER"])), 2); + // Blank rows identify nothing, so they never imply a scroll. + let blanks = rows(&["", "", "", ""]); + assert_eq!(scrolled_off(&blanks, &blanks), 0); + // A merely repeated line is not a scroll to that line. + assert_eq!(scrolled_off(&prev, &rows(&["b", "ZZ", "YY", "XX"])), 0); + } + + /// The bug this exists for: codex never enters the alternate screen and + /// scrolls inside a DECSTBM region, whose evicted lines vt100 discards by + /// spec. Both of linkshell's old paths therefore produced nothing. + #[test] + fn codex_style_region_scrolling_is_captured_as_history() { + let mut s = tui(SessionKind::Codex, 10, 40); + // Reserve the bottom three rows for a composer, exactly as codex does. + s.process_bytes(b"\x1b[1;7r", true); + assert!(!s.screen.screen().alternate_screen(), "normal screen"); + + // Fill the region, then scroll it well past its height. + for i in 1..=20 { + s.process_bytes(format!("\x1b[7;1H\r\n line {i}").as_bytes(), true); + } + + assert_eq!( + s.screen.screen().scrollback(), + 0, + "vt100 keeps no scrollback for a restricted region — the premise" + ); + let seen = history(&s); + assert!(seen.len() >= 10, "captured {} lines: {seen:?}", seen.len()); + assert!(seen.iter().any(|l| l.contains("line 1")), "{seen:?}"); + assert!(seen.iter().any(|l| l.contains("line 9")), "{seen:?}"); + // In order, and without duplicates from the repeated repaints. + let numbers: Vec = seen + .iter() + .filter_map(|l| l.trim().strip_prefix("line ")?.parse().ok()) + .collect(); + assert!( + numbers.windows(2).all(|w| w[1] > w[0]), + "out of order or duplicated: {numbers:?}" + ); + } + + /// A chunk can scroll by more than one line — codex emits runs of + /// newlines — and the old capture only ever kept the row that happened to + /// pass through the top. + #[test] + fn a_multi_line_scroll_in_one_chunk_keeps_every_line() { + let mut s = tui(SessionKind::Codex, 8, 40); + s.process_bytes(b"\x1b[1;5r", true); // rows 0..4 scroll, 5..7 pinned + s.process_bytes( + b"\x1b[5;1Hone\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix\r\n", + true, + ); + let seen = history(&s); + for want in ["one", "two"] { + assert!( + seen.iter().any(|l| l.trim() == want), + "lost {want}: {seen:?}" + ); + } + } + + /// The complement: with no region and no alternate screen, vt100 keeps + /// the scrollback itself, and capturing too would double every line. + #[test] + fn a_full_screen_scroll_is_left_to_vt100() { + let mut s = tui(SessionKind::Codex, 6, 40); + s.process_bytes( + b"one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix\r\nseven\r\n", + true, + ); + // Screen::scrollback() is the current offset, not the stored length, + // so ask whether vt100 will scroll back at all. + s.screen.set_scrollback(2); + assert_eq!(s.screen.screen().scrollback(), 2, "vt100 holds it"); + assert!(history(&s).is_empty(), "we do not: {:?}", history(&s)); + } + + /// Codex resets its region dozens of times a second, so a chunk routinely + /// straddles the change. Diffing across it compares the transcript with + /// the composer pinned below and reads as a scroll — which is what put + /// banner and composer fragments into the history. + #[test] + fn a_region_change_mid_chunk_does_not_capture_the_pinned_rows() { + let mut s = tui(SessionKind::Codex, 8, 40); + s.process_bytes(b"\x1b[1;5r", true); + // Draw a composer into the pinned rows, then scroll the region and + // reset it in one chunk, exactly as codex frames its output. + s.process_bytes(b"\x1b[7;1H> composer prompt\x1b[8;1Hmodel: gpt", true); + s.process_bytes(b"\x1b[5;1Halpha\r\nbeta\r\n\x1b[r", true); + s.process_bytes(b"\x1b[1;5r\x1b[5;1Hgamma\r\n\x1b[r", true); + + let seen = history(&s); + for banned in ["composer", "model: gpt"] { + assert!( + !seen.iter().any(|l| l.contains(banned)), + "captured a pinned row ({banned}): {seen:?}" + ); + } + } + + #[test] + fn decstbm_segments_split_after_each_region_change() { + let segs = decstbm_segments(b"aa\x1b[1;5rbb\x1b[rcc"); + let parts: Vec<&[u8]> = segs.iter().map(|(b, _)| *b).collect(); + assert_eq!( + parts, + vec![&b"aa\x1b[1;5r"[..], &b"bb\x1b[r"[..], &b"cc"[..]] + ); + assert_eq!(segs[0].1.as_deref(), Some(&b"1;5"[..])); + assert_eq!(segs[1].1.as_deref(), Some(&b""[..])); + assert_eq!(segs[2].1, None); + // No DECSTBM: one segment, unchanged. + let segs = decstbm_segments(b"plain\x1b[2Jtext"); + assert_eq!(segs.len(), 1); + assert_eq!(segs[0].0, b"plain\x1b[2Jtext"); + } + + #[test] + fn may_scroll_skips_pure_repaints() { + assert!(!may_scroll(b"\x1b[1;1H\x1b[Khello\x1b[2;1Hworld")); + assert!(may_scroll(b"hello\r\n")); + assert!(may_scroll(b"\x1b[2S")); + assert!(may_scroll(b"\x1bD")); + } + + /// The safety property that lets this run on the normal screen: a TUI + /// redrawing in place must not be mistaken for one that scrolled. + #[test] + fn an_in_place_repaint_captures_nothing() { + let mut s = tui(SessionKind::Codex, 6, 40); + s.process_bytes(b"\x1b[1;1Halpha\x1b[2;1Hbeta", true); + let before = history(&s).len(); + for _ in 0..20 { + // Same content, rewritten — a spinner frame, say. + s.process_bytes(b"\x1b[1;1H\x1b[Kalpha\x1b[2;1H\x1b[Kbeta", true); + } + assert_eq!(history(&s).len(), before, "{:?}", history(&s)); + } + + #[test] + fn shells_are_left_to_vt100s_own_scrollback() { + let mut s = tui(SessionKind::Shell, 4, 40); + for i in 0..20 { + s.process_bytes(format!("line {i}\r\n").as_bytes(), true); + } + assert!( + s.screen.screen().scrollback() > 0 || !s.kind.captures_scrollback(), + "a shell scrolls the real grid, so vt100 holds its history" + ); + assert!( + history(&s).is_empty(), + "and we do not double-capture it: {:?}", + history(&s) + ); + } + #[test] fn fmt_count_tiers_and_width() { assert_eq!(fmt_count(0), "0"); @@ -1192,7 +1633,7 @@ mod tests { } #[test] - fn alt_screen_scrolled_lines_captured_in_output_lines() { + fn alt_screen_scrolled_lines_are_captured_as_scrollback() { let mut s = Session::new( 1, "codex".into(), @@ -1206,19 +1647,19 @@ mod tests { b"\x1b[?1049h\x1b[HLine A\r\nLine B\r\nLine C\r\nLine D\r\nLine E", true, ); - assert!(s.output_lines.is_empty()); + assert!(s.scrollback_lines.is_empty()); // Scroll up by 1: Line A leaves the top, everything shifts up s.process_bytes(b"\x1b[1S", true); - assert_eq!(s.output_lines.len(), 1); - assert_eq!(s.output_lines.front().unwrap(), "Line A"); + assert_eq!(s.scrollback_lines.len(), 1); + assert_eq!(s.scrollback_lines.front().unwrap(), "Line A"); // Scroll up by 1 more: Line B leaves the top s.process_bytes(b"\x1b[1S", true); - assert_eq!(s.output_lines.len(), 2); - assert_eq!(s.output_lines.get(1).unwrap(), "Line B"); + assert_eq!(s.scrollback_lines.len(), 2); + assert_eq!(s.scrollback_lines.get(1).unwrap(), "Line B"); } #[test] diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..d8d320a --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,340 @@ +//! Colour palette for the TUI. +//! +//! Colour used to be decided inline at ~170 call sites in `ui.rs`, as a mix of +//! `Color::` literals and a pair of ad-hoc helpers. That made restyling a +//! find-and-replace across the file and made a terminal-specific fallback +//! impossible to express at all. Every colour now comes from one `Theme`, +//! carried on `App` so the `draw_*` fns reach it through the `&App` they +//! already take. +//! +//! Three bases: +//! +//! - `classic` — the palette linkshell has always shipped. Truecolor default, +//! so adopting this module changed nothing on screen. +//! - `ansi16` — named ANSI colours only, for terminals that don't report +//! truecolor. Auto-selected when `COLORTERM` says nothing. +//! - `dark` — the quieter restyle palette: one accent, reserved for focus. +//! +//! `[theme]` in linkshell.toml picks the base and overrides any field with a +//! hex string. + +use ratatui::style::Color; + +use crate::config::ThemeConfig; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Theme { + /// Terminal default background. Overlays and the footer paint `surface` + /// over it; everything else leaves it alone. + pub bg: Color, + /// Fill for the footer strip and overlay bodies. + pub surface: Color, + /// Rules, separators, inactive glyphs — structure you should not read. + pub chrome: Color, + pub text: Color, + pub text_dim: Color, + pub text_bright: Color, + /// Focus and active state, and nothing else. The restyle's whole premise + /// is that this colour means one thing. + pub accent: Color, + /// WAITING, context pressure. + pub warn: Color, + /// ERROR. + pub err: Color, + /// READY, healthy. + pub ok: Color, + /// Token counters. + pub info: Color, + /// Context-window counters. + pub ctx: Color, + /// Money. + pub cost: Color, + /// Pipe glyphs and pipe labels. + pub pipe: Color, + /// Foreground for text sitting on an `accent`/`warn` fill. + pub on_accent: Color, + /// Fill behind a mouse text selection. + pub sel_bg: Color, + + pub kind_claude: Color, + pub kind_codex: Color, + pub kind_opencode: Color, + pub kind_ohmypi: Color, + pub kind_aider: Color, + pub kind_shell: Color, + pub kind_custom: Color, + pub kind_orch: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self::classic() + } +} + +impl Theme { + /// The palette linkshell shipped before the restyle. Kept as the default + /// so the theme refactor was a no-op on screen. + pub fn classic() -> Self { + Self { + bg: Color::Reset, + surface: Color::Reset, + chrome: Color::DarkGray, + text: Color::Gray, + text_dim: Color::DarkGray, + text_bright: Color::White, + accent: Color::White, + warn: Color::Yellow, + err: Color::Red, + ok: Color::Green, + info: Color::Cyan, + ctx: Color::Magenta, + cost: Color::Green, + pipe: Color::Cyan, + on_accent: Color::Black, + sel_bg: Color::Blue, + kind_claude: Color::Rgb(255, 140, 0), + kind_codex: Color::Rgb(64, 128, 255), + kind_opencode: Color::Rgb(80, 200, 120), + kind_ohmypi: Color::Rgb(200, 120, 255), + kind_aider: Color::Rgb(0, 180, 180), + kind_shell: Color::White, + kind_custom: Color::Cyan, + kind_orch: Color::Rgb(255, 215, 0), + } + } + + /// Named ANSI colours only — no `Color::Rgb` anywhere, so a 16- or + /// 256-colour terminal renders the palette the user's scheme defines + /// rather than a truecolor approximation of it. + pub fn ansi16() -> Self { + Self { + bg: Color::Reset, + surface: Color::Black, + chrome: Color::DarkGray, + text: Color::Gray, + text_dim: Color::DarkGray, + text_bright: Color::White, + accent: Color::Cyan, + warn: Color::Yellow, + err: Color::Red, + ok: Color::Green, + info: Color::Cyan, + ctx: Color::Magenta, + cost: Color::Green, + pipe: Color::Cyan, + on_accent: Color::Black, + sel_bg: Color::Blue, + kind_claude: Color::Yellow, + kind_codex: Color::Blue, + kind_opencode: Color::Green, + kind_ohmypi: Color::Magenta, + kind_aider: Color::Cyan, + kind_shell: Color::White, + kind_custom: Color::Cyan, + kind_orch: Color::LightYellow, + } + } + + /// The restyle palette: desaturated chrome, one accent. + pub fn dark() -> Self { + Self { + bg: Color::Reset, + surface: Color::Rgb(24, 26, 31), + chrome: Color::Rgb(58, 63, 74), + text: Color::Rgb(168, 176, 190), + text_dim: Color::Rgb(106, 114, 128), + text_bright: Color::Rgb(226, 232, 240), + accent: Color::Rgb(95, 179, 212), + warn: Color::Rgb(224, 168, 84), + err: Color::Rgb(224, 108, 117), + ok: Color::Rgb(126, 186, 132), + info: Color::Rgb(122, 178, 200), + ctx: Color::Rgb(178, 148, 214), + cost: Color::Rgb(126, 186, 132), + pipe: Color::Rgb(122, 178, 200), + on_accent: Color::Rgb(16, 18, 22), + sel_bg: Color::Rgb(48, 70, 96), + kind_claude: Color::Rgb(214, 138, 74), + kind_codex: Color::Rgb(108, 146, 214), + kind_opencode: Color::Rgb(112, 178, 128), + kind_ohmypi: Color::Rgb(174, 138, 208), + kind_aider: Color::Rgb(96, 166, 166), + kind_shell: Color::Rgb(168, 176, 190), + kind_custom: Color::Rgb(122, 178, 200), + kind_orch: Color::Rgb(212, 178, 92), + } + } + + pub fn by_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "classic" => Some(Self::classic()), + "ansi16" | "ansi" | "16" => Some(Self::ansi16()), + "dark" => Some(Self::dark()), + _ => None, + } + } + + /// Resolve the configured theme. `base` wins when set; otherwise the + /// terminal's truecolor claim decides, because `Color::Rgb` on a terminal + /// that can't render it gets quantized to whatever is nearest, which is + /// how a carefully-picked "dim" ends up indistinguishable from "text". + pub fn resolve(cfg: &ThemeConfig) -> Self { + let base = match cfg.base.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(name) => Self::by_name(name).unwrap_or_else(|| { + eprintln!("[linkshell] unknown theme base '{name}'; using 'classic'"); + Self::classic() + }), + None if terminal_has_truecolor() => Self::classic(), + None => Self::ansi16(), + }; + base.with_overrides(cfg) + } + + fn with_overrides(mut self, cfg: &ThemeConfig) -> Self { + macro_rules! overlay { + ($($field:ident),* $(,)?) => { + $( + if let Some(raw) = cfg.$field.as_deref() { + match parse_hex(raw) { + Some(c) => self.$field = c, + None => eprintln!( + "[linkshell] [theme] {} = '{}' is not a #rrggbb colour; ignored", + stringify!($field), raw + ), + } + } + )* + }; + } + overlay!( + bg, + surface, + chrome, + text, + text_dim, + text_bright, + accent, + warn, + err, + ok, + info, + ctx, + cost, + pipe, + on_accent, + sel_bg, + kind_claude, + kind_codex, + kind_opencode, + kind_ohmypi, + kind_aider, + kind_shell, + kind_custom, + kind_orch, + ); + self + } +} + +/// Whether the terminal advertises 24-bit colour. Only `COLORTERM` is +/// trustworthy here: `TERM` says `xterm-256color` on virtually everything, +/// truecolor-capable or not. +pub fn terminal_has_truecolor() -> bool { + match std::env::var("COLORTERM") { + Ok(v) => { + let v = v.to_ascii_lowercase(); + v.contains("truecolor") || v.contains("24bit") + } + Err(_) => false, + } +} + +fn parse_hex(s: &str) -> Option { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let n = u32::from_str_radix(h, 16).ok()?; + Some(Color::Rgb( + ((n >> 16) & 0xff) as u8, + ((n >> 8) & 0xff) as u8, + (n & 0xff) as u8, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_parsing_accepts_both_forms_and_rejects_junk() { + assert_eq!(parse_hex("#5fb3d4"), Some(Color::Rgb(0x5f, 0xb3, 0xd4))); + assert_eq!(parse_hex("5FB3D4"), Some(Color::Rgb(0x5f, 0xb3, 0xd4))); + assert_eq!(parse_hex("#fff"), None); + assert_eq!(parse_hex("blue"), None); + assert_eq!(parse_hex(""), None); + } + + #[test] + fn ansi16_uses_no_truecolor() { + let t = Theme::ansi16(); + for c in [ + t.chrome, + t.text, + t.text_dim, + t.text_bright, + t.accent, + t.warn, + t.err, + t.ok, + t.info, + t.ctx, + t.cost, + t.pipe, + t.on_accent, + t.sel_bg, + t.kind_claude, + t.kind_codex, + t.kind_opencode, + t.kind_ohmypi, + t.kind_aider, + t.kind_shell, + t.kind_custom, + t.kind_orch, + ] { + assert!(!matches!(c, Color::Rgb(..)), "{c:?} is truecolor"); + } + } + + #[test] + fn base_selects_palette_and_fields_override_it() { + let cfg = ThemeConfig { + base: Some("dark".into()), + accent: Some("#ff0000".into()), + ..Default::default() + }; + let t = Theme::resolve(&cfg); + assert_eq!(t.accent, Color::Rgb(0xff, 0, 0)); + assert_eq!(t.chrome, Theme::dark().chrome); + } + + #[test] + fn unknown_base_falls_back_to_classic() { + let cfg = ThemeConfig { + base: Some("neon".into()), + ..Default::default() + }; + assert_eq!(Theme::resolve(&cfg), Theme::classic()); + } + + #[test] + fn a_bad_override_is_ignored_not_fatal() { + let cfg = ThemeConfig { + base: Some("classic".into()), + warn: Some("not-a-colour".into()), + ..Default::default() + }; + assert_eq!(Theme::resolve(&cfg).warn, Theme::classic().warn); + } +} diff --git a/src/ui.rs b/src/ui.rs index ff3e867..d1d3f92 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,9 +6,12 @@ use ratatui::{ Frame, }; -use crate::app::{App, AppMode, FileBrowserState, MenuAction, NewSessionField, Selection}; +use crate::app::{ + App, AppMode, FileBrowserState, MenuAction, NewSessionField, Selection, StatusPlacement, +}; use crate::layout::LayoutTree; use crate::session::{SessionKind, SessionState}; +use crate::theme::Theme; use vt100::Screen; #[derive(Default)] @@ -100,56 +103,30 @@ fn prepare_display(s: &str) -> String { out } -// ── Brand colours ────────────────────────────────────────────────────────── -const CLAUDE_COLOR: Color = Color::Rgb(255, 140, 0); // orange -const CODEX_COLOR: Color = Color::Rgb(64, 128, 255); // blue -const SHELL_COLOR: Color = Color::White; -const CUSTOM_COLOR: Color = Color::Cyan; -const OPENCODE_COLOR: Color = Color::Rgb(80, 200, 120); // green -const OHMYPI_COLOR: Color = Color::Rgb(200, 120, 255); // purple -const AIDER_COLOR: Color = Color::Rgb(0, 180, 180); // teal -const ORCH_COLOR: Color = Color::Rgb(255, 215, 0); // gold - -fn kind_color(kind: &SessionKind) -> Color { +fn kind_color(t: &Theme, kind: &SessionKind) -> Color { match kind { - SessionKind::Claude => CLAUDE_COLOR, - SessionKind::Codex => CODEX_COLOR, - SessionKind::OpenCode => OPENCODE_COLOR, - SessionKind::OhMyPi => OHMYPI_COLOR, - SessionKind::Aider => AIDER_COLOR, - SessionKind::Shell => SHELL_COLOR, - SessionKind::Custom(_) => CUSTOM_COLOR, - } -} - -fn state_border_style(state: &SessionState, active: bool) -> Style { - match state { - SessionState::Waiting => Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - SessionState::Error => Style::default() - .fg(Color::Red) - .add_modifier(Modifier::RAPID_BLINK), - SessionState::Dead => Style::default().fg(Color::DarkGray), - _ if active => Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - _ => Style::default().fg(Color::DarkGray), + SessionKind::Claude => t.kind_claude, + SessionKind::Codex => t.kind_codex, + SessionKind::OpenCode => t.kind_opencode, + SessionKind::OhMyPi => t.kind_ohmypi, + SessionKind::Aider => t.kind_aider, + SessionKind::Shell => t.kind_shell, + SessionKind::Custom(_) => t.kind_custom, } } /// Smallest terminal the main layout can be solved for: the vertical split -/// below asks for a 5-row main pane, a 3-row session bar and a status panel of -/// at least 4 rows. Under that, ratatui's solver starts handing back -/// zero-height rects and the geometry arithmetic downstream has nothing valid -/// to work from. -const MIN_ROWS: u16 = 12; +/// below asks for a tab strip and its rule (2 rows), a 5-row main pane, a +/// status panel of at least 4 rows and a footer row. Under that, ratatui's solver starts +/// handing back zero-height rects and the geometry arithmetic downstream has +/// nothing valid to work from. +const MIN_ROWS: u16 = 13; const MIN_COLS: u16 = 20; pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { let size = f.size(); if size.height < MIN_ROWS || size.width < MIN_COLS { - draw_too_small(f, size); + draw_too_small(f, &app.theme, size); // An empty LayoutInfo is safe: every consumer either iterates these // vectors or length-checks before indexing, so hit-testing simply finds // nothing until the terminal is large enough to lay out again. @@ -172,28 +149,65 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { }; // ── Top-level vertical split ─────────────────────────────────────────── - // main output | session bar | status panel + // tab strip | rule | main output | status panel | footer + // + // The strip sits above the output rather than below it: it names what is + // in the pane, and a label under the thing it labels reads as a caption + // for whatever comes next. let orch_row = if app.orchestrator.is_some() || app.orchestrator_session_id.is_some() { 1u16 } else { 0 }; - let desired_status_rows = app.visible_indices().len().max(1) as u16 + 4 + orch_row; - let capped = desired_status_rows.min((body.height / 3).max(4)); - // Hysteresis so a changing row count can't oscillate the pane layout - // (and with it the sessions' PTY sizes). - let status_rows = app - .stabilized_status_rows(capped) - .min((body.height / 3).max(4)); + // The left sidebar (the default) takes columns from the output rather + // than rows, so it claims none of the vertical budget. + let sidebar = sidebar_width(app, body.width); + // The bottom region: two-line rows want twice the height, which the + // height/3 cap reaches sooner, so `draw_status_panel` drops the detail + // line past four sessions rather than showing half the sessions. The + // sidebar has no such cap — it gets the whole column — which is the main + // reason it is the default. + let bottom_docked = app.status_docked() && app.status_placement() == StatusPlacement::Bottom; + let status_rows = if bottom_docked { + let rows_per_session = if app.visible_indices().len() <= 4 { + 2 + } else { + 1 + }; + let desired = app.visible_indices().len().max(1) as u16 * rows_per_session + 3 + orch_row; + let capped = desired.min((body.height / 3).max(4)); + // Hysteresis so a changing row count can't oscillate the pane layout + // (and with it the sessions' PTY sizes). + app.stabilized_status_rows(capped) + .min((body.height / 3).max(4)) + } else { + 0 + }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // main output - Constraint::Length(3), // session bar - Constraint::Length(status_rows), // status panel + Constraint::Length(1), // tab strip + Constraint::Length(1), // rule + Constraint::Min(5), // middle: sidebar + output + Constraint::Length(status_rows), // bottom status region + Constraint::Length(1), // footer ]) .split(body); + // The tab strip and footer stay full width — they are chrome for the + // whole window, not for the output pane — so only the middle band is + // split for the sidebar. + let (sidebar_area, output_region) = match sidebar { + Some(w) if w > 0 && chunks[2].width > w => { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(w), Constraint::Min(1)]) + .split(chunks[2]); + (Some(cols[0]), cols[1]) + } + _ => (None, chunks[2]), + }; + let mut chat_area = Rect::default(); let mut chat_layout = ChatLayout::default(); @@ -202,16 +216,16 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { // planning against remain visible and their state keeps updating. let planning_fullscreen = app.planning_fullscreen && app.planning_docked.is_some(); let output_areas = if planning_fullscreen { - draw_planning_in(f, app, chunks[0], true); + draw_planning_in(f, app, output_region, true); // No pane rects: hit-testing must find nothing where no session was // drawn, and the PTYs keep the size they last laid out at. Vec::new() } else { - split_output_areas(chunks[0], &app.tree) + split_output_areas(output_region, &app.tree) }; for (pane_idx, area) in output_areas.iter().copied().enumerate() { if app.chat_docked == Some(pane_idx) && output_areas.len() > 1 { - chat_layout = draw_chat_in(f, app, area, pane_idx == app.focused_pane); + chat_layout = draw_chat_in(f, app, area, pane_idx == app.focused_pane, false); chat_area = chat_layout.area; } else if app.planning_docked == Some(pane_idx) && output_areas.len() > 1 { draw_planning_in(f, app, area, pane_idx == app.focused_pane); @@ -219,8 +233,14 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { draw_pane_output(f, app, area, pane_idx, pane_idx == app.focused_pane); } } - let slot_areas = draw_session_bar(f, app, chunks[1]); - let status_row_areas = draw_status_panel(f, app, chunks[2]); + let slot_areas = draw_tab_strip(f, app, chunks[0]); + draw_rule(f, &app.theme, chunks[1]); + let mut status_row_areas = if let Some(area) = sidebar_area { + draw_status_sidebar(f, app, area) + } else { + draw_status_panel(f, app, chunks[3], bottom_docked) + }; + draw_footer(f, app, chunks[4]); // ── Overlays ─────────────────────────────────────────────────────────── let mut new_session_area = Rect::default(); @@ -239,7 +259,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { let ns_result = draw_new_session_dialog(f, app, size); new_session_area = ns_result.0; browse_button_area = ns_result.1; - file_browser_area = draw_file_browser(f, &app.file_browser_state, size); + file_browser_area = draw_file_browser(f, &app.theme, &app.file_browser_state, size); } AppMode::CommandBar => { command_bar_area = draw_command_bar(f, app, size); @@ -248,7 +268,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { draw_command_result(f, app, size); } AppMode::Help => { - help_area = draw_help(f, size); + help_area = draw_help(f, &app.theme, size); } AppMode::PipeList => { help_area = draw_pipe_list(f, app, size); @@ -272,12 +292,19 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { AppMode::Settings => { help_area = draw_settings_overlay(f, app, size); } + AppMode::Status => { + // Sized to its content, capped at the terminal: an overlay that + // is mostly empty box reads as a panel that failed to load. + let rows = app.visible_indices().len().max(1) as u16 * 2 + 3 + orch_row * 2; + help_area = centered_rect(70, rows.min(size.height), size); + status_row_areas = draw_status_panel(f, app, help_area, false); + } AppMode::Normal => {} } LayoutInfo { output_areas, - session_bar_area: chunks[1], + session_bar_area: chunks[0], session_slot_areas: slot_areas, status_row_areas, new_session_area, @@ -302,27 +329,141 @@ fn split_output_areas(area: Rect, tree: &LayoutTree) -> Vec { // ── Main output zone ─────────────────────────────────────────────────────── +/// Where a pane's content lives inside its outer rect — the single definition +/// of that geometry. +/// +/// Three consumers have to agree on this number: the renderer, the PTY size +/// sent to the session, and the mouse→vt100 column mapping for selection. When +/// they were three separate `saturating_sub(2)`s spread across `ui.rs`, +/// `main.rs` and `app.rs`, changing the chrome meant changing all three and +/// noticing if you didn't — a silently 2-column-wider PTY renders fine and +/// mis-maps every drag-selection. They now all call this. +/// +/// The layout: column 0 is the focus bar, column 1 is padding, row 0 is the +/// title. Content width is deliberately `width - 2`, identical to what +/// `Borders::ALL` yielded, so replacing the box resized no PTY. Height is +/// `height - 1` rather than `height - 2`: dropping the bottom border is a row +/// of output back. +pub fn pane_content_area(area: Rect) -> Rect { + Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(1), + } +} + +/// Draw a pane's chrome and return its content rect. +/// +/// In place of a box: a title line, and a one-column bar down the left edge. +/// That bar does double duty — `▎` in the accent marks the focused pane, `│` +/// in chrome separates a pane from its neighbour — so a split needs no +/// per-pane boxes and no separate divider. +fn draw_pane_frame( + f: &mut Frame<'_>, + t: &Theme, + area: Rect, + focused: bool, + title: Vec>, +) -> Rect { + if area.width == 0 || area.height == 0 { + return pane_content_area(area); + } + let (glyph, style) = if focused { + ("▎", Style::default().fg(t.accent)) + } else { + ("│", Style::default().fg(t.chrome)) + }; + for y in area.y..area.y + area.height { + f.render_widget( + Paragraph::new(Span::styled(glyph, style)), + Rect { + x: area.x, + y, + width: 1, + height: 1, + }, + ); + } + if area.width > 2 { + f.render_widget( + Paragraph::new(Line::from(title)), + Rect { + x: area.x + 2, + y: area.y, + width: area.width - 2, + height: 1, + }, + ); + } + pane_content_area(area) +} + +/// A pane title: `● alpha · ~/src/linkshell`. Filled dot and a bright name +/// when focused, hollow and dim when not — the same distinction the border +/// colour used to carry, in the one row that replaced it. +fn pane_title( + t: &Theme, + focused: bool, + kind: Color, + name: &str, + detail: &str, + trailing: Option<(String, Style)>, +) -> Vec> { + let mut spans = vec![ + Span::styled( + if focused { "● " } else { "○ " }, + Style::default().fg(if focused { kind } else { t.chrome }), + ), + Span::styled( + name.to_string(), + if focused { + Style::default() + .fg(t.text_bright) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(t.text_dim) + }, + ), + ]; + if !detail.is_empty() { + spans.push(Span::styled(" · ", Style::default().fg(t.chrome))); + spans.push(Span::styled( + detail.to_string(), + Style::default().fg(t.text_dim), + )); + } + if let Some((text, style)) = trailing { + spans.push(Span::styled(text, style)); + } + spans +} + fn draw_pane_output(f: &mut Frame<'_>, app: &App, area: Rect, pane_idx: usize, focused: bool) { - let (title, lines, border_style) = if let Some(idx) = app.panes[pane_idx] { + let t = &app.theme; + let (title, lines) = if let Some(idx) = app.panes[pane_idx] { let session = &app.sessions[idx]; let screen = session.screen.screen(); let (screen_rows, screen_cols) = screen.size(); - let display_rows = area.height.saturating_sub(2); + let display_rows = pane_content_area(area).height; let scroll_offset = screen.scrollback().max(session.history_scroll) as u16; // vt100 handles the scrollback offset internally via set_scrollback; // just display the bottom display_rows rows of the virtual screen. let start_row = screen_rows.saturating_sub(display_rows); let end_row = screen_rows; + // Scrolled off the live tail is a state you can be stuck in without + // realising, so it gets the title's trailing slot rather than a colour. let scroll_indicator = if scroll_offset > 0 { - format!(" ↑{}", scroll_offset) + Some((format!(" ↑{}", scroll_offset), Style::default().fg(t.warn))) } else { - String::new() + None }; - let title = format!( - " {} [{}] {}{} ", - idx + 1, - session.kind.label().to_uppercase(), - session.name, + let title = pane_title( + t, + focused, + kind_color(t, &session.kind), + &session.name, + &contract_home(std::path::Path::new(&session.cwd)), scroll_indicator, ); let sel = if focused { @@ -335,18 +476,23 @@ fn draw_pane_output(f: &mut Frame<'_>, app: &App, area: Rect, pane_idx: usize, f // Alternate-screen apps have no vt100 scrollback; show a window of // our captured line history instead. history_scroll counts lines // up from the tail of output_lines. - let total = session.output_lines.len(); - let end = total.saturating_sub(session.history_scroll); + let history = session.history_lines(); + let total = history.len(); + let rows = (display_rows as usize).min(total); + // Scrolled fully to the top, `total - history_scroll` reaches 0 + // and the window collapses to nothing — a scrollback that goes + // blank exactly when you reach the beginning of it. Hold the + // window open at the oldest full page instead. + let end = total.saturating_sub(session.history_scroll).max(rows); let start = end.saturating_sub(display_rows as usize); - session - .output_lines + history .iter() .skip(start) .take(end - start) .map(|l| { ListItem::new(Line::from(Span::styled( l.clone(), - Style::default().fg(Color::Gray), + Style::default().fg(t.text), ))) }) .collect() @@ -361,143 +507,396 @@ fn draw_pane_output(f: &mut Frame<'_>, app: &App, area: Rect, pane_idx: usize, f } else { None }; - build_row(screen, vt_row, screen_cols, disp_row, sel, cursor_col) + build_row(t, screen, vt_row, screen_cols, disp_row, sel, cursor_col) }) .collect() }; - let style = state_border_style(&session.state, focused); - (title, items, style) + (title, items) } else { let message = if app.sessions.is_empty() { - " No sessions. Press alt-n to create one." + "No sessions. Press alt-n to create one." } else { - " No session in this pane. Use a session switch key." + "No session in this pane. Use a session switch key." }; - let items = vec![ListItem::new(Line::from(message))]; - ( - if app.sessions.is_empty() { - " linkshell ".to_string() - } else { - " no session ".to_string() - }, - items, - Style::default().fg(Color::DarkGray), - ) + let items = vec![ListItem::new(Line::from(Span::styled( + message, + Style::default().fg(t.text_dim), + )))]; + let name = if app.sessions.is_empty() { + "linkshell" + } else { + "no session" + }; + (pane_title(t, focused, t.chrome, name, "", None), items) }; - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style); + let content = draw_pane_frame(f, t, area, focused, title); + f.render_widget(List::new(lines), content); +} + +// ── Tab strip ────────────────────────────────────────────────────────────── + +/// Longest session name a tab will render before ellipsizing. Past this a +/// single verbosely-named session starts costing its neighbours their names. +const MAX_TAB_NAME: usize = 12; - let list = List::new(lines).block(block); - f.render_widget(list, area); +/// A session's state as a suffix glyph on its tab. +/// +/// The bordered slot boxes this replaced carried state in their border +/// colour, which a one-row strip has nowhere to put. A glyph is what keeps +/// the at-a-glance property when the status panel is closed — the point of +/// the strip is that you can stop keeping the panel open, and that only works +/// if "which agent wants me" survives the move. +fn tab_marker(t: &Theme, session: &crate::session::Session) -> Option<(&'static str, Style)> { + if session.paused { + return Some(("⏸", Style::default().fg(t.text_dim))); + } + match session.state { + SessionState::Waiting => Some(( + "!", + Style::default().fg(t.warn).add_modifier(Modifier::BOLD), + )), + SessionState::Error => Some(("✕", Style::default().fg(t.err).add_modifier(Modifier::BOLD))), + SessionState::Dead => Some(("✕", Style::default().fg(t.text_dim))), + _ => None, + } } -// ── Session bar ──────────────────────────────────────────────────────────── +/// Width of one tab, given its rendered name: `" N name! "`. +fn tab_width(number: usize, name: &str, marked: bool) -> u16 { + let digits = if number >= 10 { 2 } else { 1 }; + let name_w = if name.is_empty() { + 0 + } else { + name.chars().count() + 1 + }; + (1 + digits + name_w + usize::from(marked) + 1) as u16 +} -fn draw_session_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { - // Slots (and the returned click rects) cover visible sessions only; - // mouse handling maps a slot position back through visible_to_idx. +/// A single-row tab strip, replacing the three-row bar of bordered slot +/// boxes. Returns one click rect per visible session, in `visible_indices` +/// order, exactly as the slot boxes did — `app.rs` maps a hit back through +/// `visible_to_idx`, and that contract is what makes click-to-focus work. +fn draw_tab_strip(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { + let t = &app.theme; let visible = app.visible_indices(); - let n = visible.len(); - // outer block - let outer = Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM); - f.render_widget(outer, area); + f.render_widget(Paragraph::new("").style(Style::default().bg(t.bg)), area); + + let mut x = area.x; + let right = area.x + area.width; - // Broadcast mode indicator + // Broadcast is a mode that changes where every keystroke goes; it earns + // the leading columns and pushes the tabs right rather than overlaying + // them. if app.broadcast_mode { - let indicator = Rect { - x: area.x + 1, - y: area.y, - width: 13, - height: 1, - }; + let label = " BROADCAST "; + let w = (label.chars().count() as u16).min(area.width); f.render_widget( - Paragraph::new("[BROADCAST]") - .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - indicator, + Paragraph::new(Span::styled( + label, + Style::default() + .fg(t.on_accent) + .bg(t.err) + .add_modifier(Modifier::BOLD), + )), + Rect { + x, + y: area.y, + width: w, + height: 1, + }, ); + x += w; } - if n == 0 { + if visible.is_empty() { return vec![]; } - // inner area (remove outer border) - let inner = Rect { - x: area.x + 1, - y: area.y, - width: area.width.saturating_sub(2), - height: area.height, + // Overflow ladder: full names, then names only on the active tab, then + // bare indices, then truncate. Trying each in turn is cheaper to reason + // about than solving for a width budget, and there are only three rungs. + let active = app.active_idx(); + let budget = right.saturating_sub(x); + // The session name, not its kind: three shells all reading "shell" is + // the case the strip most needs to disambiguate, and the kind is already + // carried by the tab's colour. + let names: Vec = visible + .iter() + .map(|&idx| { + let session = &app.sessions[idx]; + let name = if session.name.is_empty() { + session.kind.label() + } else { + session.name.as_str() + }; + ellipsize(name, MAX_TAB_NAME) + }) + .collect(); + let marked: Vec = visible + .iter() + .map(|&idx| tab_marker(t, &app.sessions[idx]).is_some()) + .collect(); + let total = |names: &[String]| -> u16 { + names + .iter() + .enumerate() + .map(|(i, n)| tab_width(i + 1, n, marked[i])) + .sum() + }; + let names = if total(&names) <= budget { + names + } else { + let active_only: Vec = visible + .iter() + .enumerate() + .map(|(i, &idx)| { + if Some(idx) == active { + names[i].clone() + } else { + String::new() + } + }) + .collect(); + if total(&active_only) <= budget { + active_only + } else { + vec![String::new(); visible.len()] + } }; - // Center the slots - let slot_w = (inner.width as usize / n).min(16) as u16; - let total_w = slot_w * n as u16; - let offset_x = inner.x + (inner.width.saturating_sub(total_w)) / 2; - + let mut rects = Vec::with_capacity(visible.len()); for (i, &idx) in visible.iter().enumerate() { let session = &app.sessions[idx]; - let slot = Rect { - x: offset_x + i as u16 * slot_w, - y: inner.y, - width: slot_w, - height: inner.height, + let is_active = active == Some(idx); + let in_pane = app.panes.contains(&Some(idx)); + let marker = tab_marker(t, session); + let want = tab_width(i + 1, &names[i], marker.is_some()); + let avail = right.saturating_sub(x); + if avail == 0 { + // Out of room entirely: the remaining tabs still need click rects + // or their indices would silently shift against visible_to_idx. + rects.push(Rect { + x: right, + y: area.y, + width: 0, + height: 1, + }); + continue; + } + let w = want.min(avail); + let rect = Rect { + x, + y: area.y, + width: w, + height: 1, }; - let is_active = app.active_idx() == Some(idx); - let is_visible = app.panes.contains(&Some(idx)); - let label = format!("{} {}", i + 1, session.kind.label()); - let color = kind_color(&session.kind); - let border_style = state_border_style(&session.state, is_active); - - let title_style = if is_active { - Style::default().fg(color).add_modifier(Modifier::BOLD) - } else if is_visible { - Style::default() - .fg(color) - .add_modifier(Modifier::UNDERLINED) + // Active tab: reversed accent, so focus is legible without relying on + // the agent's brand colour, which varies in contrast by kind. + let (base, num_style, name_style) = if is_active { + let base = Style::default().bg(t.accent).fg(t.on_accent); + ( + base, + base.add_modifier(Modifier::BOLD), + base.add_modifier(Modifier::BOLD), + ) } else { - Style::default().fg(color) + let base = Style::default().bg(t.bg); + let name = Style::default().fg(kind_color(t, &session.kind)); + ( + base, + Style::default().fg(t.text_dim), + if in_pane { + name.add_modifier(Modifier::UNDERLINED) + } else { + name + }, + ) }; - let block = Block::default() - .title(Span::styled(label, title_style)) - .borders(Borders::ALL) - .border_style(border_style); + let mut spans = vec![ + Span::styled(" ", base), + Span::styled(format!("{}", i + 1), num_style), + ]; + if !names[i].is_empty() { + spans.push(Span::styled(" ", base)); + spans.push(Span::styled(names[i].clone(), name_style)); + } + if let Some((glyph, style)) = marker { + // On the active tab the marker sits on the accent fill, so it + // needs that background or it punches a hole in the highlight. + spans.push(Span::styled( + glyph, + if is_active { style.bg(t.accent) } else { style }, + )); + } + spans.push(Span::styled(" ", base)); + + f.render_widget(Paragraph::new(Line::from(spans)), rect); + rects.push(rect); + x += w; + } + + rects +} + +/// The rule under the tab strip: structure, not information. +fn draw_rule(f: &mut Frame<'_>, t: &Theme, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let line = "─".repeat(area.width as usize); + f.render_widget( + Paragraph::new(Span::styled(line, Style::default().fg(t.chrome))), + area, + ); +} + +// ── Footer ───────────────────────────────────────────────────────────────── + +/// Fraction of the context window past which the counter turns amber. +const CTX_PRESSURE: f64 = 0.80; - // State dot inside slot - let state_dot = if session.paused { - Span::styled("⏸", Style::default().fg(Color::DarkGray)) +/// The active session's vitals, on one row at the bottom of the screen: +/// +/// ```text +/// claude · opus-4-8 18.0k/180k ⣾ THINKING 0:42 $0.31 alt-h help +/// ``` +/// +/// This is the row that lets the status panel be closed: it answers "what am +/// I looking at, is it working, and what has it cost" for the pane in front +/// of you, which is the question the panel was being kept open to answer. +fn draw_footer(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; + if area.width == 0 || area.height == 0 { + return; + } + let fill = Style::default().bg(t.surface); + f.render_widget(Paragraph::new("").style(fill), area); + + let Some(session) = app.active_session() else { + f.render_widget( + Paragraph::new(Span::styled( + " no session — alt-n to create one", + fill.fg(t.text_dim), + )), + area, + ); + return; + }; + + // A paused or dead session's numbers are all frozen; dimming the whole + // row says that once, instead of each field having to say it. + let inert = session.paused || session.state == SessionState::Dead; + let dim = |style: Style| if inert { fill.fg(t.text_dim) } else { style }; + + let name_style = dim(match session.state { + SessionState::Waiting => fill.fg(t.warn).add_modifier(Modifier::BOLD), + SessionState::Error | SessionState::Dead => fill.fg(t.err).add_modifier(Modifier::BOLD), + SessionState::Thinking | SessionState::Running => { + fill.fg(t.accent).add_modifier(Modifier::BOLD) + } + _ => fill.fg(t.text_bright).add_modifier(Modifier::BOLD), + }); + + // Segments in drop order: the tail is shed first as the terminal + // narrows, so the identity of the session outlives the hint text. + let mut head: Vec = vec![Span::styled(" ", fill)]; + head.push(Span::styled( + ellipsize(&session.name, MAX_TAB_NAME), + name_style, + )); + if let Some(model) = session.model.as_deref() { + head.push(Span::styled(" · ", dim(fill.fg(t.chrome)))); + head.push(Span::styled( + model_display(Some(model)), + dim(fill.fg(t.text)), + )); + } + + let mut segments: Vec> = Vec::new(); + + // Context: the number whose meaning changes when you switch models, and + // the one worth a colour when it starts running out. With no known + // window there is no denominator and no threshold to colour against — + // rendering a bare count beats inventing one. + if session.stats.context_tokens > 0 { + let style = if session.context_max > 0 + && session.stats.context_tokens as f64 / session.context_max as f64 >= CTX_PRESSURE + { + fill.fg(t.warn).add_modifier(Modifier::BOLD) } else { - match session.state { - SessionState::Waiting => Span::styled("⚡", Style::default().fg(Color::Yellow)), - SessionState::Error => Span::styled("✗", Style::default().fg(Color::Red)), - SessionState::Thinking => Span::styled("…", Style::default().fg(CLAUDE_COLOR)), - SessionState::Running => Span::styled("▶", Style::default().fg(Color::Green)), - SessionState::Ready => Span::styled("●", Style::default().fg(Color::Green)), - SessionState::Dead => Span::styled("✗", Style::default().fg(Color::DarkGray)), - SessionState::Starting => Span::styled("○", Style::default().fg(Color::Gray)), - } + fill.fg(t.ctx) }; + segments.push(vec![Span::styled(session.context_display(), dim(style))]); + } - let para = Paragraph::new(Line::from(vec![state_dot])) - .block(block) - .alignment(Alignment::Center); + let state = session.state_label(); + let mut state_spans = Vec::new(); + if matches!( + session.state, + SessionState::Thinking | SessionState::Running + ) && !inert + { + state_spans.push(Span::styled( + format!("{} ", spinner_frame()), + fill.fg(t.accent), + )); + } + state_spans.push(Span::styled( + state.to_string(), + dim(match session.state { + SessionState::Waiting => fill.fg(t.warn), + SessionState::Error | SessionState::Dead => fill.fg(t.err), + _ => fill.fg(t.text), + }), + )); + if session.state == SessionState::Dead { + state_spans.push(Span::styled(" — alt-r restart", fill.fg(t.text_dim))); + } + segments.push(state_spans); + + segments.push(vec![Span::styled( + session.elapsed_display(), + dim(fill.fg(t.text_dim)), + )]); + let cost = session.cost_display(); + if cost != "—" { + segments.push(vec![Span::styled(cost, dim(fill.fg(t.cost)))]); + } - f.render_widget(para, slot); + // Assemble left to right, dropping trailing segments that don't fit + // rather than wrapping — a wrapped footer would eat an output row. + let width = area.width as usize; + let mut spans = head; + let mut used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + let hint = if app.status_docked() { + " alt-h help " + } else { + " alt-s status " + }; + let hint_w = hint.chars().count(); + for segment in segments { + let seg_w: usize = segment.iter().map(|s| s.content.chars().count()).sum(); + if used + 3 + seg_w > width { + break; + } + spans.push(Span::styled(" ", fill)); + spans.extend(segment); + used += 3 + seg_w; } - (0..n) - .map(|i| Rect { - x: offset_x + i as u16 * slot_w, - y: inner.y, - width: slot_w, - height: inner.height, - }) - .collect() + // The hint is the first thing to go, so it is placed last and only if + // what remains is genuinely spare. + if used + hint_w <= width { + let pad = width - used - hint_w; + spans.push(Span::styled(" ".repeat(pad), fill)); + spans.push(Span::styled(hint, fill.fg(t.text_dim))); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); } // ── Status panel ─────────────────────────────────────────────────────────── @@ -531,13 +930,10 @@ struct OrchRow { cost: String, } -fn truncate(s: &str, n: usize) -> String { - s.chars().take(n).collect() -} - fn orchestrator_row(app: &App) -> Option { - let green = Style::default().fg(Color::Green); - let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + let t = &app.theme; + let green = Style::default().fg(t.ok); + let red = Style::default().fg(t.err).add_modifier(Modifier::BOLD); if let Some(h) = &app.orchestrator { // API-class: in-process task. Failed when its channel is gone. @@ -562,13 +958,11 @@ fn orchestrator_row(app: &App) -> Option { state_style: if !alive { red } else if app.orchestrator_paused { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD) } else if busy { - Style::default().fg(CLAUDE_COLOR) + Style::default().fg(t.kind_claude) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) }, tokens: if total == 0 { "—".into() @@ -605,11 +999,9 @@ fn orchestrator_row(app: &App) -> Option { state_style: if failed { red } else if s.paused { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::Green) + Style::default().fg(t.ok) }, tokens: s.tokens_display(), ctx: s.context_display(), @@ -617,240 +1009,593 @@ fn orchestrator_row(app: &App) -> Option { }) } -fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { - let title = match &app.council { +/// One session's two lines in the status panel. +/// +/// Line 1 is the vitals; line 2 is the identity — model, working directory, +/// and which sessions this one is piped to or from. The pipe direction is +/// genuinely new: the old column table had a 20-column `Pipe` cell that +/// truncated a second peer out of existence. +fn status_block( + app: &App, + session: &crate::session::Session, + width: usize, +) -> (Line<'static>, Line<'static>) { + let t = &app.theme; + let (dot, dot_style) = match session.state { + SessionState::Error | SessionState::Dead => { + ("●", Style::default().fg(t.err).add_modifier(Modifier::BOLD)) + } + SessionState::Starting => ("○", Style::default().fg(t.text)), + _ => ("●", Style::default().fg(t.ok)), + }; + let state_style = if session.paused { + Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD) + } else { + match session.state { + SessionState::Waiting => Style::default().fg(t.warn).add_modifier(Modifier::BOLD), + SessionState::Error => Style::default().fg(t.err).add_modifier(Modifier::BOLD), + SessionState::Thinking => Style::default().fg(t.kind_claude), + SessionState::Running => Style::default().fg(t.ok), + SessionState::Ready => Style::default().fg(t.text_dim), + SessionState::Dead => Style::default().fg(t.text_dim), + SessionState::Starting => Style::default().fg(t.text), + } + }; + + // Right-aligned numbers get fixed-width padding rather than `│` + // separators. The separators were what drifted out of alignment whenever + // a field rendered wider than its column, and they carried no meaning + // that the padding doesn't. + let vitals = Line::from(vec![ + Span::styled(format!(" {dot} "), dot_style), + Span::styled( + format!("{:<14} ", ellipsize(&session.name, 14)), + Style::default() + .fg(kind_color(t, &session.kind)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!("{:<9} ", session.state_label()), state_style), + Span::styled( + format!("{:>7} ", session.elapsed_display()), + Style::default().fg(t.text_dim), + ), + Span::styled( + format!("{:>8} ", session.tokens_display()), + Style::default().fg(t.info), + ), + Span::styled( + format!("{:>13} ", session.context_display()), + Style::default().fg(t.ctx), + ), + Span::styled( + format!("{:>8}", session.cost_display()), + Style::default().fg(t.cost), + ), + ]); + + let mut detail = model_display(session.model.as_deref()); + if !session.cwd.is_empty() { + detail.push_str(" · "); + detail.push_str(&contract_home(std::path::Path::new(&session.cwd))); + } + let glyphs = app.pipe_summary_for(session.id, std::time::Instant::now()); + let (out, inn): (Vec<_>, Vec<_>) = glyphs.iter().partition(|g| g.outgoing); + for (arrow, group) in [("→", &out), ("←", &inn)] { + if group.is_empty() { + continue; + } + let peers: Vec<&str> = group.iter().map(|g| g.peer.as_str()).collect(); + detail.push_str(&format!(" · {arrow} {}", peers.join(","))); + } + + let detail = Line::from(Span::styled( + format!(" {}", ellipsize(&detail, width.saturating_sub(5))), + Style::default().fg(t.text_dim), + )); + (vitals, detail) +} + +/// Header for the status panel — the council round is the only thing here +/// that changes, and it changes often enough to be worth the row. +fn status_title(app: &App) -> String { + match &app.council { Some(r) if r.complete => format!(" Status ── council '{}' done ", r.group), Some(r) => format!( " Status ── council '{}' round {}/{} ", r.group, r.round, r.max_rounds ), None => " Status ".to_string(), + } +} + +// ── Status sidebar ───────────────────────────────────────────────────────── + +/// Columns the rail falls back to when the terminal can't afford the full +/// sidebar: a state glyph and an index per session, and nothing else. +pub const SIDEBAR_RAIL_COLS: u16 = 3; + +/// Columns the output pane must keep before the sidebar gives way to the +/// rail. Below this a split pane stops being usable for anything. +const MIN_OUTPUT_COLS: u16 = 60; + +/// How wide the sidebar should actually be drawn, given the terminal width. +/// +/// Returns `None` when the panel isn't docked to the left at all. The rail is +/// what makes "permanently there" survive a narrow terminal: the sidebar's +/// job is to tell you which agent needs you, and a glyph plus an index still +/// does that in three columns. +pub fn sidebar_width(app: &App, total_width: u16) -> Option { + if !app.status_docked() || app.status_placement() != StatusPlacement::Left { + return None; + } + let want = app.config.general.status_panel_width.clamp(16, 60); + if total_width.saturating_sub(want) >= MIN_OUTPUT_COLS { + Some(want) + } else { + Some(SIDEBAR_RAIL_COLS) + } +} + +/// The rail: one row per session, `●1` / `!3` / `✕4`. +fn draw_status_rail(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { + let t = &app.theme; + let mut rects = Vec::new(); + for (i, &idx) in app.visible_indices().iter().enumerate() { + let y = area.y + i as u16; + if y >= area.y + area.height { + break; + } + let session = &app.sessions[idx]; + let (glyph, style) = match tab_marker(t, session) { + Some((glyph, style)) => (glyph, style), + None => ( + "●", + Style::default().fg(if app.active_idx() == Some(idx) { + t.accent + } else { + t.ok + }), + ), + }; + let row = Rect { + x: area.x, + y, + width: area.width, + height: 1, + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(glyph, style), + Span::styled(format!("{}", i + 1), Style::default().fg(t.text_dim)), + ])), + row, + ); + rects.push(row); + } + rects +} + +/// One session as a stacked block, for the sidebar's narrow column: +/// +/// ```text +/// ● alpha 1m32s +/// READY 18.0k/180k +/// opus-4-8 $0.31 +/// ``` +/// +/// The horizontal row the bottom panel uses needs 70 columns; restacking is +/// what makes the panel affordable as a sidebar at all. +fn sidebar_block( + app: &App, + session: &crate::session::Session, + width: usize, + lines: usize, +) -> Vec> { + let t = &app.theme; + let active = app.active_session().is_some_and(|s| s.id == session.id); + let (glyph, glyph_style) = match tab_marker(t, session) { + Some((glyph, style)) => (glyph, style), + None => ( + "●", + Style::default().fg(if active { t.accent } else { t.ok }), + ), + }; + let state_style = if session.paused { + Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD) + } else { + match session.state { + SessionState::Waiting => Style::default().fg(t.warn).add_modifier(Modifier::BOLD), + SessionState::Error => Style::default().fg(t.err).add_modifier(Modifier::BOLD), + SessionState::Thinking => Style::default().fg(t.kind_claude), + SessionState::Running => Style::default().fg(t.ok), + _ => Style::default().fg(t.text_dim), + } }; - let block = Block::default() - .title(title) - .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM); - let inner = block.inner(area); - f.render_widget(block, area); + // Each line is a left field and a right field, with the gap between them + // doing the aligning — the sidebar is too narrow for fixed columns. + let pair = |left: Span<'static>, right: Span<'static>| -> Line<'static> { + let used = left.content.chars().count() + right.content.chars().count() + 1; + let gap = width.saturating_sub(used).max(1); + Line::from(vec![ + Span::raw(" "), + left, + Span::raw(" ".repeat(gap)), + right, + ]) + }; - // Header row - if inner.height > 0 { - let hdr_style = Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD); - let header_spans = vec![ - Span::styled(" ⏻ ", hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:<8} ", "Kind"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:<12} ", "Model"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:<20} ", "Pipe"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:<8} ", "State"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:>6} ", "Time"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:>6} ", "Tokens"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:>13} ", "Ctx"), hdr_style), - Span::styled("│ ", hdr_style), - Span::styled(format!("{:>7}", "Cost"), hdr_style), - ]; - let header_row = Rect { + let name_width = width.saturating_sub(session.elapsed_display().chars().count() + 4); + let mut out = vec![pair( + Span::styled( + format!("{glyph} {}", ellipsize(&session.name, name_width.max(4))), + if active { + Style::default() + .fg(t.text_bright) + .add_modifier(Modifier::BOLD) + } else { + glyph_style + }, + ), + Span::styled(session.elapsed_display(), Style::default().fg(t.text_dim)), + )]; + if lines >= 2 { + out.push(pair( + Span::styled(format!(" {}", session.state_label()), state_style), + Span::styled(session.context_display(), Style::default().fg(t.ctx)), + )); + } + if lines >= 3 { + // A shell has no model and no cost, and a line reading "- —" is a + // line spent saying nothing. Its working directory is the identity + // that actually distinguishes it from the shell in the next pane. + let cost = session.cost_display(); + let left = match session.model.as_deref() { + Some(model) => model_display(Some(model)), + None => contract_home(std::path::Path::new(&session.cwd)), + }; + let room = width.saturating_sub(cost.chars().count() + 4); + out.push(pair( + Span::styled( + format!(" {}", ellipsize(&left, room.max(4))), + Style::default().fg(t.text_dim), + ), + Span::styled( + if cost == "—" { String::new() } else { cost }, + Style::default().fg(t.cost), + ), + )); + } + out +} + +/// The left sidebar. Returns one click rect per visible session, on each +/// block's first line, in `visible_indices` order. +fn draw_status_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { + let t = &app.theme; + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + if area.width <= SIDEBAR_RAIL_COLS { + return draw_status_rail(f, app, area); + } + + let visible = app.visible_indices(); + // A rule down the right edge separates the sidebar from the output; the + // output pane draws its own focus bar just past it. + for y in area.y..area.y + area.height { + f.render_widget( + Paragraph::new(Span::styled("│", Style::default().fg(t.chrome))), + Rect { + x: area.x + area.width - 1, + y, + width: 1, + height: 1, + }, + ); + } + let inner = Rect { + width: area.width - 1, + ..area + }; + + // Two rows of chrome: the title and the totals footer. + let budget = inner.height.saturating_sub(2) as usize; + let n = visible.len().max(1); + // Pick the tallest block that fits, down to a single line. Unlike the + // bottom panel there is no height/3 cap to fight — a sidebar has the + // whole column, which is the main reason it holds more sessions. + let (lines, gap) = if n * 4 <= budget { + (3usize, 1usize) + } else if n * 3 <= budget { + (3, 0) + } else if n * 2 <= budget { + (2, 0) + } else { + (1, 0) + }; + + f.render_widget( + Paragraph::new(Span::styled( + format!( + " {}", + ellipsize(status_title(app).trim(), inner.width as usize - 1) + ), + Style::default() + .fg(t.text_bright) + .add_modifier(Modifier::BOLD), + )), + Rect { height: 1, ..inner }, + ); + + let mut rects = Vec::new(); + let mut y = inner.y + 1; + let last = inner.y + inner.height - 1; + for &idx in &visible { + if y >= last { + break; + } + let session = &app.sessions[idx]; + let block = sidebar_block(app, session, inner.width as usize - 1, lines); + let first = Rect { x: inner.x, - y: inner.y, + y, width: inner.width, height: 1, }; - f.render_widget(Paragraph::new(Line::from(header_spans)), header_row); + for line in block { + if y >= last { + break; + } + f.render_widget( + Paragraph::new(line), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + y += 1; + } + rects.push(first); + y += gap as u16; } - let mut row_areas = Vec::new(); - let mut row_y = inner.y + 1; - // Rows (and the returned click rects) cover visible sessions only, in - // the same order as the session bar slots. - for idx in app.visible_indices() { - let session = &app.sessions[idx]; - if row_y >= inner.y + inner.height { - break; + if let Some(o) = orchestrator_row(app) { + if y < last { + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {} ", o.dot), o.dot_style), + Span::styled( + ellipsize(&o.name, inner.width as usize / 2), + Style::default() + .fg(t.kind_orch) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {}", o.state), o.state_style), + ])), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); } + } - let row = Rect { + // Totals on the last row. + let total_cost: f64 = visible + .iter() + .map(|&i| app.sessions[i].stats.total_cost_usd) + .sum(); + let mut footer = vec![Span::styled( + format!(" {} sess", visible.len()), + Style::default().fg(t.text_dim), + )]; + if total_cost > 0.0 { + let cost = format!("${total_cost:.2}"); + let used = 6 + visible.len().to_string().len() + cost.chars().count() + 1; + footer.push(Span::raw( + " ".repeat((inner.width as usize).saturating_sub(used).max(1)), + )); + footer.push(Span::styled(cost, Style::default().fg(t.cost))); + } + f.render_widget( + Paragraph::new(Line::from(footer)), + Rect { x: inner.x, - y: row_y, + y: last, width: inner.width, height: 1, - }; - row_y += 1; - row_areas.push(row); + }, + ); - // Health indicator: red when the agent has failed (Error/Dead), - // green once it is connected and healthy again. - let (health_dot, health_style) = match session.state { - SessionState::Error | SessionState::Dead => ( - "●", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - SessionState::Starting => ("○", Style::default().fg(Color::Gray)), - _ => ("●", Style::default().fg(Color::Green)), - }; - let kind_style = Style::default().fg(kind_color(&session.kind)); - let state_style = if session.paused { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD) - } else { - match session.state { - SessionState::Waiting => Style::default() - .fg(Color::Yellow) + rects +} + +/// The status panel, in either of its two modes. +/// +/// `docked` draws it into a region below the output, as it always was; +/// otherwise it is the alt-s overlay, which claims no layout rows and so +/// resizes no PTY when it opens. Returns one click rect per visible session, +/// in `visible_indices` order — `app.rs` maps a hit back through +/// `visible_to_idx`, and that holds in both modes. +fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect, docked: bool) -> Vec { + let t = &app.theme; + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + let visible = app.visible_indices(); + + let block = if docked { + Block::default() + .title(status_title(app)) + .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM) + } else { + f.render_widget(Clear, area); + Block::default() + .title(Span::styled( + status_title(app), + Style::default() + .fg(t.text_bright) .add_modifier(Modifier::BOLD), - SessionState::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - SessionState::Thinking => Style::default().fg(CLAUDE_COLOR), - SessionState::Running => Style::default().fg(Color::Green), - SessionState::Ready => Style::default().fg(Color::DarkGray), - SessionState::Dead => Style::default().fg(Color::DarkGray), - SessionState::Starting => Style::default().fg(Color::Gray), - } - }; + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(t.chrome)) + .style(Style::default().bg(t.surface)) + }; + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return Vec::new(); + } - let tokens = session.tokens_display(); - let context = session.context_display(); - let cost = session.cost_display(); - let elapsed = session.elapsed_display(); + // Docked mode reserves rows from the output pane, and a two-line row + // doubles what it takes. Past four sessions that is more of the screen + // than the panel is worth, so the detail line is what gives way — the + // overlay still has it, and alt-s is one keystroke. + let two_line = !docked || visible.len() <= 4; + let per_row: u16 = if two_line { 2 } else { 1 }; + // The last inner row belongs to the totals footer. + let body_rows = inner.height.saturating_sub(1); - let glyphs = app.pipe_summary_for(session.id, std::time::Instant::now()); - let mut labels: Vec = glyphs - .iter() - .take(2) - .map(|glyph| { - format!( - "{}{}{}", - if glyph.outgoing { "→" } else { "←" }, - glyph.peer, - if glyph.recent { " ●" } else { "" } - ) - }) - .collect(); - if glyphs.len() > 2 { - labels.push(format!("+{}", glyphs.len() - 2)); + let mut row_areas = Vec::new(); + let mut y = inner.y; + for &idx in &visible { + if y + per_row > inner.y + body_rows { + break; + } + let session = &app.sessions[idx]; + let (vitals, detail) = status_block(app, session, inner.width as usize); + let row = Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }; + f.render_widget(Paragraph::new(vitals), row); + // The click rect is the vitals line only: a two-row target would + // overlap the next session's block on the boundary. + row_areas.push(row); + y += 1; + if two_line { + f.render_widget( + Paragraph::new(detail), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + y += 1; } - let pipe_label = labels.join(","); - let pipe_recently_fired = glyphs.iter().any(|glyph| glyph.recent); - let all_inactive = !glyphs.is_empty() && glyphs.iter().all(|glyph| !glyph.active); - - let spans = vec![ - Span::styled(format!(" {health_dot} "), health_style), - Span::raw("│ "), - Span::styled(format!("{:<8} ", session.kind.label()), kind_style), - Span::raw("│ "), - Span::styled( - format!("{:<12} ", model_display(session.model.as_deref())), - Style::default().fg(Color::Gray), - ), - Span::raw("│ "), - Span::styled(format!("{:<20} ", pipe_label), { - let s = Style::default().fg(Color::Cyan); - if all_inactive { - s.add_modifier(Modifier::DIM) - } else if pipe_recently_fired { - s.add_modifier(Modifier::BOLD) - } else { - s - } - }), - Span::raw("│ "), - Span::styled(format!("{:<8} ", session.state_label()), state_style), - Span::raw("│ "), - Span::styled( - format!("{:>6} ", elapsed), - Style::default().fg(Color::Gray), - ), - Span::raw("│ "), - Span::styled(format!("{:>6} ", tokens), Style::default().fg(Color::Cyan)), - Span::raw("│ "), - Span::styled( - format!("{:>13} ", context), - Style::default().fg(Color::Magenta), - ), - Span::raw("│ "), - Span::styled(format!("{:>7}", cost), Style::default().fg(Color::Green)), - ]; - - let line = Paragraph::new(Line::from(spans)); - f.render_widget(line, row); } - // Orchestrator agent row — after the session rows so the returned click - // rects still map 1:1 onto visible sessions. Covers both flavors: the + // Orchestrator row — after the session rows so the returned click rects + // still map 1:1 onto visible sessions. Covers both flavors: the // in-process API-class handle and the hidden CLI-class session. - let orch = orchestrator_row(app); - if let Some(o) = orch { - if row_y < inner.y + inner.height { - let row = Rect { - x: inner.x, - y: row_y, - width: inner.width, - height: 1, - }; + if let Some(o) = orchestrator_row(app) { + if y < inner.y + body_rows { let spans = vec![ - Span::styled(format!(" {} ", o.dot), o.dot_style), - Span::raw("│ "), + Span::styled(format!(" {} ", o.dot), o.dot_style), Span::styled( - format!("{:<8} ", truncate(&o.name, 8)), - Style::default().fg(ORCH_COLOR).add_modifier(Modifier::BOLD), - ), - Span::raw("│ "), - Span::styled( - format!("{:<12} ", model_display(o.model.as_deref())), - Style::default().fg(Color::Gray), - ), - Span::raw("│ "), - Span::styled( - format!("{:<20} ", "orchestrator"), - Style::default().fg(Color::DarkGray), - ), - Span::raw("│ "), - Span::styled(format!("{:<8} ", o.state), o.state_style), - Span::raw("│ "), - Span::styled(format!("{:>6} ", ""), Style::default().fg(Color::Gray)), - Span::raw("│ "), - Span::styled( - format!("{:>6} ", o.tokens), - Style::default().fg(Color::Cyan), - ), - Span::raw("│ "), - Span::styled( - format!("{:>13} ", o.ctx), - Style::default().fg(Color::Magenta), + format!("{:<14} ", ellipsize(&o.name, 14)), + Style::default() + .fg(t.kind_orch) + .add_modifier(Modifier::BOLD), ), - Span::raw("│ "), - Span::styled(format!("{:>7}", o.cost), Style::default().fg(Color::Green)), + Span::styled(format!("{:<9} ", o.state), o.state_style), + Span::styled(format!("{:>7} ", ""), Style::default().fg(t.text_dim)), + Span::styled(format!("{:>8} ", o.tokens), Style::default().fg(t.info)), + Span::styled(format!("{:>13} ", o.ctx), Style::default().fg(t.ctx)), + Span::styled(format!("{:>8}", o.cost), Style::default().fg(t.cost)), ]; - f.render_widget(Paragraph::new(Line::from(spans)), row); + f.render_widget( + Paragraph::new(Line::from(spans)), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + y += 1; + if two_line && y < inner.y + body_rows { + f.render_widget( + Paragraph::new(Span::styled( + format!(" orchestrator · {}", model_display(o.model.as_deref())), + Style::default().fg(t.text_dim), + )), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + } } } - - // Socket path footer — always at the last row of inner area - if inner.height > 0 { - let sock = crate::ipc::socket_path(&app.config); - let footer_row = Rect { + + // Totals footer — always the last inner row. + let total_tokens: u64 = visible + .iter() + .map(|&i| app.sessions[i].stats.input_tokens + app.sessions[i].stats.output_tokens) + .sum(); + let total_cost: f64 = visible + .iter() + .map(|&i| app.sessions[i].stats.total_cost_usd) + .sum(); + let mut footer = vec![ + Span::styled( + format!( + " {} session{}", + visible.len(), + if visible.len() == 1 { "" } else { "s" } + ), + Style::default().fg(t.text), + ), + Span::styled(" ", Style::default()), + Span::styled( + crate::session::fmt_count(total_tokens), + Style::default().fg(t.info), + ), + Span::styled(" tokens", Style::default().fg(t.text_dim)), + ]; + if total_cost > 0.0 { + footer.push(Span::styled(" ", Style::default())); + footer.push(Span::styled( + format!("${total_cost:.3}"), + Style::default().fg(t.cost), + )); + } + if docked { + // The socket path has no home in the overlay, which is transient; in + // the always-on panel it is the thing you copy to point an agent here. + footer.push(Span::styled(" ", Style::default())); + footer.push(Span::styled( + format!("sock: {}", crate::ipc::socket_path(&app.config)), + Style::default().fg(t.text_dim).add_modifier(Modifier::DIM), + )); + } else { + footer.push(Span::styled(" ", Style::default())); + footer.push(Span::styled( + "esc to close", + Style::default().fg(t.text_dim), + )); + } + f.render_widget( + Paragraph::new(Line::from(footer)), + Rect { x: inner.x, y: inner.y + inner.height - 1, width: inner.width, height: 1, - }; - let footer = Paragraph::new(Line::from(vec![ - Span::styled(" sock: ", Style::default().fg(Color::DarkGray)), - Span::styled( - sock, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - ), - ])); - f.render_widget(footer, footer_row); - } + }, + ); row_areas } @@ -859,6 +1604,7 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { /// Returns (popup_rect, browse_button_rect). pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rect, Rect) { + let t = &app.theme; let ns = &app.new_session_state; let height = if ns.is_custom() { 16 } else { 13 }; let popup = centered_rect(50, height, area); @@ -874,10 +1620,10 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec let kind_active = ns.active_field == NewSessionField::Kind; let kind_style = if kind_active { Style::default() - .fg(Color::White) + .fg(t.text_bright) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) }; let kind_label = crate::session::KIND_LABELS .get(ns.selected_kind) @@ -895,11 +1641,8 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec .borders(Borders::ALL) .border_style(kind_style); let kind_text = Line::from(vec![ - Span::styled( - format!(" {}", kind_label), - Style::default().fg(Color::Yellow), - ), - Span::styled(format!(" {}", arrow), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {}", kind_label), Style::default().fg(t.warn)), + Span::styled(format!(" {}", arrow), Style::default().fg(t.text_dim)), ]); f.render_widget(Paragraph::new(kind_text).block(kind_block), kind_field); @@ -908,6 +1651,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec // Name field draw_input_field( f, + t, "Name", &ns.name, ns.name_cursor, @@ -925,6 +1669,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec let cwd_input_w = inner.width.saturating_sub(BROWSE_BTN_W); draw_input_field( f, + t, "CWD", &ns.cwd, ns.cwd_cursor, @@ -944,7 +1689,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec width: BROWSE_BTN_W, height: 3, }; - let browse_style = Style::default().fg(Color::Cyan); + let browse_style = Style::default().fg(t.info); let browse_block = Block::default() .borders(Borders::ALL) .border_style(browse_style); @@ -957,6 +1702,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec if ns.is_custom() { draw_input_field( f, + t, "Command", &ns.custom_cmd, ns.custom_cmd_cursor, @@ -977,7 +1723,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec " Tab: next field Alt+B: browse Enter: create Esc: cancel " }; let hint = Paragraph::new(hint_text) - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(t.text_dim)) .alignment(Alignment::Center); f.render_widget( hint, @@ -995,7 +1741,7 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec f.render_widget(Clear, list); let list_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::White)); + .border_style(Style::default().fg(t.text_bright)); let list_inner = list_block.inner(list); f.render_widget(list_block, list); for (i, label) in crate::session::KIND_LABELS.iter().enumerate() { @@ -1005,11 +1751,11 @@ pub fn draw_new_session_dialog(f: &mut Frame<'_>, app: &App, area: Rect) -> (Rec } let style = if i == ns.selected_kind { Style::default() - .fg(Color::Black) - .bg(Color::White) + .fg(t.on_accent) + .bg(t.text_bright) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::Gray) + Style::default().fg(t.text) }; let row = Rect { x: list_inner.x, @@ -1043,7 +1789,12 @@ pub fn kind_dropdown_list_rect(popup: Rect) -> Rect { pub const FILE_BROWSER_VISIBLE_ROWS: usize = 16; -pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect) -> Rect { +pub fn draw_file_browser( + f: &mut Frame<'_>, + t: &Theme, + state: &FileBrowserState, + area: Rect, +) -> Rect { const VISIBLE_ROWS: u16 = FILE_BROWSER_VISIBLE_ROWS as u16; let popup = centered_rect(60, VISIBLE_ROWS + 6, area); f.render_widget(Clear, popup); @@ -1051,13 +1802,13 @@ pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect let block = Block::default() .title(" Browse Directory ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(t.info)); let inner = block.inner(popup); f.render_widget(block, popup); // Current path line let path_str = state.current_dir.to_string_lossy(); - let path_line = Paragraph::new(path_str.as_ref()).style(Style::default().fg(Color::Yellow)); + let path_line = Paragraph::new(path_str.as_ref()).style(Style::default().fg(t.warn)); f.render_widget( path_line, Rect { @@ -1069,8 +1820,8 @@ pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect ); // Separator - let sep = Paragraph::new("─".repeat(inner.width as usize)) - .style(Style::default().fg(Color::DarkGray)); + let sep = + Paragraph::new("─".repeat(inner.width as usize)).style(Style::default().fg(t.text_dim)); f.render_widget( sep, Rect { @@ -1096,11 +1847,11 @@ pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect let label = state.entry_label(i); let style = if i == state.selected { Style::default() - .fg(Color::Black) - .bg(Color::Cyan) + .fg(t.on_accent) + .bg(t.info) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(t.text_bright) }; ListItem::new(format!(" {label}")).style(style) }) @@ -1112,7 +1863,7 @@ pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect // Footer let footer = Paragraph::new(" ↑↓: navigate Enter: open dir Space: select current Esc: cancel ") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(t.text_dim)) .alignment(Alignment::Center); f.render_widget( footer, @@ -1129,6 +1880,7 @@ pub fn draw_file_browser(f: &mut Frame<'_>, state: &FileBrowserState, area: Rect fn draw_input_field( f: &mut Frame<'_>, + t: &Theme, label: &str, value: &str, cursor_pos: usize, @@ -1136,9 +1888,9 @@ fn draw_input_field( active: bool, ) { let style = if active { - Style::default().fg(Color::Cyan) + Style::default().fg(t.info) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) }; let block = Block::default() .title(format!(" {} ", label)) @@ -1157,7 +1909,7 @@ fn draw_input_field( } else { (" ", "") }; - let cursor_style = Style::default().bg(Color::Cyan).fg(Color::Black); + let cursor_style = Style::default().bg(t.info).fg(t.on_accent); let line = Line::from(vec![ Span::raw(before), Span::styled(cursor_ch, cursor_style), @@ -1172,7 +1924,7 @@ fn draw_input_field( // ── Help overlay ─────────────────────────────────────────────────────────── -fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { +fn draw_help(f: &mut Frame<'_>, t: &Theme, area: Rect) -> Rect { const BINDINGS: &[(&str, &str)] = &[ ("alt-n", "New session dialog"), ("alt-tab", "Next session"), @@ -1207,11 +1959,9 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { let inner = block.inner(popup); f.render_widget(block, popup); - let key_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); + let key_style = Style::default().fg(t.warn).add_modifier(Modifier::BOLD); let desc_style = Style::default(); - let sep_style = Style::default().fg(Color::DarkGray); + let sep_style = Style::default().fg(t.text_dim); let rows: Vec = BINDINGS .iter() @@ -1228,7 +1978,7 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { f.render_widget(list, inner); let footer = Paragraph::new(" press any key to close ") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(t.text_dim)) .alignment(Alignment::Center); f.render_widget( footer, @@ -1244,6 +1994,7 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { } fn draw_pipe_list(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { + let t = &app.theme; let height = (app.pipes.len() as u16 + 4).clamp(6, 18); let popup = centered_rect(90, height, area); f.render_widget(Clear, popup); @@ -1287,9 +2038,9 @@ fn draw_pipe_list(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { if pipe.active { "active" } else { "paused" }, ); let style = if index == app.pipe_list_selected { - Style::default().fg(Color::Black).bg(Color::Cyan) + Style::default().fg(t.on_accent).bg(t.info) } else if !pipe.active { - Style::default().fg(Color::Gray).add_modifier(Modifier::DIM) + Style::default().fg(t.text).add_modifier(Modifier::DIM) } else { Style::default() }; @@ -1347,17 +2098,13 @@ fn wrap_text(text: &str, width: usize) -> Vec { out } -fn chat_from_style(from: &str) -> Style { +fn chat_from_style(t: &Theme, from: &str) -> Style { if from.starts_with("you") { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + Style::default().fg(t.info).add_modifier(Modifier::BOLD) } else if from == "linkshell" { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) } else { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) + Style::default().fg(t.warn).add_modifier(Modifier::BOLD) } } @@ -1404,12 +2151,19 @@ fn draw_chat(f: &mut Frame<'_>, app: &App, area: Rect) -> ChatLayout { let height_pct = app.config.chat.height_pct.clamp(20, 95); let height_rows = (area.height as u32 * height_pct as u32 / 100).max(8) as u16; let popup = centered_rect(width_pct, height_rows, area); - draw_chat_in(f, app, popup, true) + draw_chat_in(f, app, popup, true, true) } /// Render the chat into an exact rect — used by both the centered overlay /// and the docked split pane. `focused` drives the border colour. -fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> ChatLayout { +fn draw_chat_in( + f: &mut Frame<'_>, + app: &App, + popup: Rect, + focused: bool, + boxed: bool, +) -> ChatLayout { + let t = &app.theme; f.render_widget(Clear, popup); let target = app @@ -1418,19 +2172,36 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha .as_deref() .map(|t| format!("@{}", t)) .unwrap_or_else(|| "no target".to_string()); - let block = Block::default() - .title(format!( - " Chat ─ {} (@name msg · /cmd · /agents · esc) ", - target - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(if focused { - Color::Cyan - } else { - Color::DarkGray - })); - let inner = block.inner(popup); - f.render_widget(block, popup); + // A floating overlay keeps its box — it needs an edge to read as floating. + // Docked into a split leaf it is a pane like any other, and wears the + // same chrome, or the two halves of a split look like two applications. + let inner = if boxed { + let block = Block::default() + .title(format!( + " Chat ─ {} (@name msg · /cmd · /agents · esc) ", + target + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { t.info } else { t.text_dim })); + let inner = block.inner(popup); + f.render_widget(block, popup); + inner + } else { + draw_pane_frame( + f, + t, + popup, + focused, + pane_title( + t, + focused, + t.info, + "chat", + &format!("{target} (@name msg · /cmd · esc)"), + None, + ), + ) + }; let width = inner.width as usize; @@ -1446,13 +2217,13 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha Some(ch) => (&after[..ch.len_utf8()], &after[ch.len_utf8()..]), None => (" ", ""), }; - let prompt_style = Style::default().fg(Color::Cyan); - let cursor_style = Style::default().fg(Color::Black).bg(Color::White); + let prompt_style = Style::default().fg(t.info); + let cursor_style = Style::default().fg(t.on_accent).bg(t.text_bright); // Pasted newlines stay in the string but render as a single '⏎' glyph so // the char↔cell math below holds. let display = |c: char, style: Style| { if c == '\n' { - ('⏎', style.patch(Style::default().fg(Color::DarkGray))) + ('⏎', style.patch(Style::default().fg(t.text_dim))) } else { (c, style) } @@ -1483,7 +2254,7 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha for (i, l) in wrap_text(&m.text, body_width).into_iter().enumerate() { if i == 0 { lines.push(Line::from(vec![ - Span::styled(prefix.clone(), chat_from_style(&m.from)), + Span::styled(prefix.clone(), chat_from_style(t, &m.from)), Span::raw(l), ])); } else { @@ -1495,7 +2266,7 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha let waiting: Vec<&str> = app.chat.pending.iter().map(|p| p.name.as_str()).collect(); lines.push(Line::from(Span::styled( format!("… awaiting {}", waiting.join(", ")), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), ))); } if let Some((status, since)) = &app.orchestrator_status { @@ -1515,7 +2286,7 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha }, status ), - Style::default().fg(Color::Yellow), + Style::default().fg(t.warn), ))); } if let Some(p) = &app.pending_proposal { @@ -1524,9 +2295,7 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha "⏸ {} proposes {}: {} (/approve · /deny [reason])", app.config.orchestrator.name, p.tool, p.detail ), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), + Style::default().fg(t.ctx).add_modifier(Modifier::BOLD), ))); } @@ -1592,7 +2361,7 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha f.render_widget( Paragraph::new(Line::from(Span::styled( sep_text, - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), ))), sep, ); @@ -1641,9 +2410,9 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha .enumerate() .map(|(index, entry)| { let style = if index == app.chat.palette.selected { - Style::default().fg(Color::Black).bg(Color::Cyan) + Style::default().fg(t.on_accent).bg(t.info) } else { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(t.text_bright).bg(t.text_dim) }; Line::from(vec![ Span::styled(format!(" {:<30}", entry.template), style), @@ -1678,6 +2447,7 @@ fn palette_popup_rows(match_len: usize, area_height: u16) -> u16 { } fn draw_command_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { + let t = &app.theme; if area.height == 0 || area.width == 0 { return Rect::default(); } @@ -1698,9 +2468,9 @@ fn draw_command_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { .enumerate() .map(|(index, entry)| { let style = if index == app.palette.selected { - Style::default().fg(Color::Black).bg(Color::Cyan) + Style::default().fg(t.on_accent).bg(t.info) } else { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(t.text_bright).bg(t.text_dim) }; Line::from(vec![ Span::styled(format!(" {:<38}", entry.template), style), @@ -1735,16 +2505,17 @@ fn draw_command_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { Span::raw(before.to_string()), Span::styled( cursor_ch.to_string(), - Style::default().fg(Color::Black).bg(Color::White), + Style::default().fg(t.on_accent).bg(t.text_bright), ), Span::raw(after.to_string()), ]); - let p = Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::DarkGray)); + let p = Paragraph::new(line).style(Style::default().fg(t.text_bright).bg(t.text_dim)); f.render_widget(p, bar); bar } fn draw_command_result(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; let bar = Rect { x: area.x, y: area.y + area.height - 1, @@ -1753,20 +2524,21 @@ fn draw_command_result(f: &mut Frame<'_>, app: &App, area: Rect) { }; f.render_widget(Clear, bar); let line = Line::from(vec![ - Span::styled("» ", Style::default().fg(Color::Yellow).bg(Color::DarkGray)), + Span::styled("» ", Style::default().fg(t.warn).bg(t.text_dim)), Span::raw(app.command_result.clone()), Span::styled( " [any key to close]", - Style::default().fg(Color::Gray).bg(Color::DarkGray), + Style::default().fg(t.text).bg(t.text_dim), ), ]); f.render_widget( - Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::DarkGray)), + Paragraph::new(line).style(Style::default().fg(t.text_bright).bg(t.text_dim)), bar, ); } fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, Vec) { + let t = &app.theme; let (selected_top, selected_sub) = match app.mode { AppMode::Menu { selected_top, @@ -1804,7 +2576,7 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, spans.push(Span::styled(text, style)); } f.render_widget( - Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::DarkGray)), + Paragraph::new(Line::from(spans)).style(Style::default().bg(t.text_dim)), area, ); @@ -1845,12 +2617,12 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, if item.action == MenuAction::Separator { return ListItem::new(Line::from(Span::styled( "─".repeat(inner.width as usize), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), ))); } let selected = idx == sub_idx; let base = if !item.enabled { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) } else if selected { Style::default().add_modifier(Modifier::REVERSED) } else { @@ -1869,7 +2641,7 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, let detail_style = if selected || !item.enabled { base } else { - base.fg(Color::Cyan) + base.fg(t.info) }; spans.push(Span::styled(format!("{} ", item.detail), detail_style)); } else if selected { @@ -1898,6 +2670,7 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, // ── Search overlay ───────────────────────────────────────────────────────── fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { + let t = &app.theme; let (query, cursor, matches, selected) = match &app.mode { AppMode::Search { query, @@ -1916,7 +2689,7 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { let block = Block::default() .title(" Search (↑↓ navigate Enter jump Esc cancel) ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(t.info)); let inner = block.inner(popup); f.render_widget(block, popup); @@ -1944,9 +2717,9 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { } else { (" ", "") }; - let cursor_style = Style::default().bg(Color::Cyan).fg(Color::Black); + let cursor_style = Style::default().bg(t.info).fg(t.on_accent); let input_line = Line::from(vec![ - Span::styled("> ", Style::default().fg(Color::Yellow)), + Span::styled("> ", Style::default().fg(t.warn)), Span::raw(before.to_string()), Span::styled(cursor_ch.to_string(), cursor_style), Span::raw(after.to_string()), @@ -1964,8 +2737,7 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { height: 1, }; f.render_widget( - Paragraph::new("─".repeat(inner.width as usize)) - .style(Style::default().fg(Color::DarkGray)), + Paragraph::new("─".repeat(inner.width as usize)).style(Style::default().fg(t.text_dim)), sep_area, ); @@ -1989,9 +2761,9 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { .map(|(i, &line_idx)| { let text = session_lines.get(line_idx).cloned().unwrap_or_default(); let style = if i == selected { - Style::default().fg(Color::Black).bg(Color::Cyan) + Style::default().fg(t.on_accent).bg(t.info) } else { - Style::default().fg(Color::Gray) + Style::default().fg(t.text) }; ListItem::new(format!(" {:4}: {}", line_idx + 1, text)).style(style) }) @@ -2017,11 +2789,7 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { }; f.render_widget( Paragraph::new(count_text) - .style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - ) + .style(Style::default().fg(t.text_dim).add_modifier(Modifier::DIM)) .alignment(Alignment::Center), footer_area, ); @@ -2033,6 +2801,7 @@ fn draw_search_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { // ── Settings overlay ──────────────────────────────────────────────────────── fn draw_settings_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { + let t = &app.theme; let ss = &app.settings_state; let n_fields = ss.fields.len() as u16; let height = (n_fields + 6).min(area.height); @@ -2042,7 +2811,7 @@ fn draw_settings_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { let block = Block::default() .title(" Settings (↑↓ navigate Enter edit s save Esc cancel) ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)); + .border_style(Style::default().fg(t.warn)); let inner = block.inner(popup); f.render_widget(block, popup); @@ -2077,9 +2846,9 @@ fn draw_settings_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { }; let row_style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Yellow) + Style::default().fg(t.on_accent).bg(t.warn) } else { - Style::default().fg(Color::White) + Style::default().fg(t.text_bright) }; let text = format!(" {:<28} │ {}", field.label, value_str); ListItem::new(text).style(row_style) @@ -2098,11 +2867,7 @@ fn draw_settings_overlay(f: &mut Frame<'_>, app: &App, area: Rect) -> Rect { }; f.render_widget( Paragraph::new("Changes are saved to ~/.config/linkshell/config.toml") - .style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - ) + .style(Style::default().fg(t.text_dim).add_modifier(Modifier::DIM)) .alignment(Alignment::Center), footer_area, ); @@ -2150,6 +2915,7 @@ fn style_preserves_spaces(style: Style) -> bool { /// Build one display row, applying per-cell colors, selection highlight, and cursor. fn build_row( + t: &Theme, screen: &Screen, vt_row: u16, screen_cols: u16, @@ -2158,6 +2924,7 @@ fn build_row( cursor_col: Option, ) -> ListItem<'static> { ListItem::new(build_row_line( + t, screen, vt_row, screen_cols, @@ -2170,6 +2937,7 @@ fn build_row( /// Assemble the styled spans for one row. Split out from `build_row` so tests /// can inspect the rendered text directly. fn build_row_line( + t: &Theme, screen: &Screen, vt_row: u16, screen_cols: u16, @@ -2177,7 +2945,7 @@ fn build_row_line( sel: Option<&Selection>, cursor_col: Option, ) -> Line<'static> { - let sel_style = Style::default().bg(Color::Blue).fg(Color::White); + let sel_style = Style::default().bg(t.sel_bg).fg(t.text_bright); let cursor_style = Style::default().add_modifier(Modifier::REVERSED); let mut spans: Vec> = Vec::new(); @@ -2242,21 +3010,19 @@ fn build_row_line( /// Fallback frame for terminals below the minimum layout size. Deliberately /// uses no arithmetic on the area beyond ratatui's own clipping. -fn draw_too_small(f: &mut Frame<'_>, size: Rect) { +fn draw_too_small(f: &mut Frame<'_>, t: &Theme, size: Rect) { f.render_widget(Clear, size); let text = vec![ Line::from(Span::styled( "terminal too small", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), + Style::default().fg(t.warn).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( format!( "{}x{} — need {}x{}", size.width, size.height, MIN_COLS, MIN_ROWS ), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), ]; f.render_widget( @@ -2340,20 +3106,18 @@ fn spinner_frame() -> char { /// right a header that says what the thread is grounded in, the transcript, /// an input that grows with the paragraph you're writing, and a status row. fn draw_planning_in(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; use crate::app::PlanningFocus; f.render_widget(Clear, area); - let block = Block::default() - .title(" Planning ") - .borders(Borders::ALL) - .border_style(Style::default().fg(if focused { - Color::Cyan - } else { - Color::DarkGray - })); - let inner = block.inner(area); - f.render_widget(block, area); + let inner = draw_pane_frame( + f, + t, + area, + focused, + pane_title(t, focused, t.info, "planning", "", None), + ); if inner.width == 0 || inner.height == 0 { return; } @@ -2379,7 +3143,7 @@ fn draw_planning_in(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Line::from(""), Line::from(Span::styled( " no thread — n to start one", - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), ]); f.render_widget(hint, main_area); @@ -2440,6 +3204,7 @@ fn draw_planning_overlays(f: &mut Frame<'_>, app: &App, area: Rect) { /// Pick the session a committed plan is handed to as work. fn draw_planning_handoff(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; let targets = app.planning_handoff_targets(); let sel = app.planning.handoff.unwrap_or(0); let height = (targets.len() as u16 + 2).min(area.height).max(3); @@ -2457,7 +3222,7 @@ fn draw_planning_handoff(f: &mut Frame<'_>, app: &App, area: Rect) { }; ListItem::new(Line::from(vec![ Span::styled(format!(" {}", name), style), - Span::styled(format!(" #{}", id), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" #{}", id), Style::default().fg(t.text_dim)), ])) }) .collect(); @@ -2467,7 +3232,7 @@ fn draw_planning_handoff(f: &mut Frame<'_>, app: &App, area: Rect) { Block::default() .title(" Hand plan to ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)), + .border_style(Style::default().fg(t.ok)), ), popup, ); @@ -2475,6 +3240,7 @@ fn draw_planning_handoff(f: &mut Frame<'_>, app: &App, area: Rect) { /// Thread list: two lines per row so a title has room to breathe. fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; use crate::app::PlanningFocus; let open_id = app.planning.thread.as_ref().map(|t| t.id.as_str()); @@ -2503,11 +3269,11 @@ fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { .saturating_sub(3 + decisions_rows) .max(2); - for (i, t) in app.planning.threads.iter().enumerate() { + for (i, thread) in app.planning.threads.iter().enumerate() { if lines.len() + 2 > list_budget + 2 { break; } - let is_open = open_id == Some(t.id.as_str()); + let is_open = open_id == Some(thread.id.as_str()); let is_sel = i == app.planning.list_selected; let marker = if is_open { "▸ " } else { " " }; // The open thread stays marked even when focus is elsewhere; the @@ -2519,11 +3285,18 @@ fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { }; lines.push(Line::from(vec![ Span::raw(marker), - Span::styled(ellipsize(&t.title, width.saturating_sub(2)), title_style), + Span::styled( + ellipsize(&thread.title, width.saturating_sub(2)), + title_style, + ), ])); lines.push(Line::from(Span::styled( - format!(" {} · {} msg", relative_age(t.updated), t.messages), - Style::default().fg(Color::DarkGray), + format!( + " {} · {} msg", + relative_age(thread.updated), + thread.messages + ), + Style::default().fg(t.text_dim), ))); } @@ -2539,7 +3312,7 @@ fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { for d in decisions.iter().take(decisions_rows.saturating_sub(2)) { lines.push(Line::from(Span::styled( format!("· {}", ellipsize(d, width.saturating_sub(2))), - Style::default().fg(Color::Green), + Style::default().fg(t.ok), ))); } } @@ -2558,7 +3331,7 @@ fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { f.render_widget( Paragraph::new(Span::styled( format!(" {} thread{}", n, if n == 1 { "" } else { "s" }), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), footer, ); @@ -2571,20 +3344,21 @@ fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { /// what tells you whether the brief still describes the repo. fn draw_planning_header( f: &mut Frame<'_>, - _app: &App, + app: &App, thread: &crate::planning::store::Thread, area: Rect, ) { + let t = &app.theme; let stale = thread.stale_reads(); let reads = thread.reads.len(); let mut scope: Vec = vec![Span::styled( format!(" {} · {} files read", contract_home(&thread.root), reads), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )]; if !stale.is_empty() { scope.push(Span::styled( format!(" · {} changed since", stale.len()), - Style::default().fg(Color::Yellow), + Style::default().fg(t.warn), )); } let lines = vec![ @@ -2604,6 +3378,7 @@ fn draw_planning_transcript( thread: &crate::planning::store::Thread, area: Rect, ) { + let t = &app.theme; use crate::planning::store::Role; let width = area.width.saturating_sub(2) as usize; @@ -2621,7 +3396,7 @@ fn draw_planning_transcript( let rule_w = width.saturating_sub(label.chars().count() + 2); lines.push(Line::from(Span::styled( format!("{}{}──", "─".repeat(rule_w), label), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), ))); } prev_model = Some(m.model.clone()); @@ -2632,12 +3407,11 @@ fn draw_planning_transcript( // on text and metadata degrades rather than lying — so an empty // attribution renders as a generic label, not a blank gutter. let (label, style) = match m.role { - Role::User => ("you".to_string(), Style::default().fg(Color::DarkGray)), - Role::Assistant if attribution.trim().is_empty() => ( - "assistant".to_string(), - Style::default().fg(Color::DarkGray), - ), - Role::Assistant => (attribution, Style::default().fg(Color::Cyan)), + Role::User => ("you".to_string(), Style::default().fg(t.text_dim)), + Role::Assistant if attribution.trim().is_empty() => { + ("assistant".to_string(), Style::default().fg(t.text_dim)) + } + Role::Assistant => (attribution, Style::default().fg(t.info)), }; lines.push(Line::from(Span::styled(format!(" {}", label), style))); for l in wrap_text(&m.text, width.saturating_sub(2).max(8)) { @@ -2649,7 +3423,7 @@ fn draw_planning_transcript( if app.planning.busy && !app.planning.status.is_empty() { lines.push(Line::from(Span::styled( format!(" {} {}", spinner_frame(), app.planning.status), - Style::default().fg(Color::Yellow), + Style::default().fg(t.warn), ))); } @@ -2666,6 +3440,7 @@ fn draw_planning_transcript( /// Multi-line input. Enter sends; Alt+Enter inserts a newline, because a /// planning message is usually a paragraph. fn draw_planning_input(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; let dim = app.planning.busy; let mut pos = app.planning.cursor.min(app.planning.input.len()); while pos > 0 && !app.planning.input.is_char_boundary(pos) { @@ -2677,16 +3452,16 @@ fn draw_planning_input(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) None => (" ", ""), }; let base = if dim { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) } else { Style::default() }; - let mut spans = vec![Span::styled(" > ", Style::default().fg(Color::Cyan))]; + let mut spans = vec![Span::styled(" > ", Style::default().fg(t.info))]; spans.push(Span::styled(before.replace('\n', "⏎"), base)); if focused && !dim { spans.push(Span::styled( cursor_ch.replace('\n', "⏎"), - Style::default().fg(Color::Black).bg(Color::White), + Style::default().fg(t.on_accent).bg(t.text_bright), )); } else { spans.push(Span::styled(cursor_ch.replace('\n', "⏎"), base)); @@ -2719,13 +3494,14 @@ fn draw_planning_status( thread: &crate::planning::store::Thread, area: Rect, ) { + let t = &app.theme; let mut spans: Vec = Vec::new(); let label = app.planning.backend_label(); let backend_style = if app.planning.backend.is_none() { - Style::default().fg(Color::Red) + Style::default().fg(t.err) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) }; spans.push(Span::styled(format!(" {}", label), backend_style)); @@ -2743,11 +3519,11 @@ fn draw_planning_status( let worst = used.max(peak); let pct = worst * 100 / b.max_context_tokens.max(1); let style = if pct >= 90 { - Style::default().fg(Color::Red) + Style::default().fg(t.err) } else if pct >= 75 { - Style::default().fg(Color::Yellow) + Style::default().fg(t.warn) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(t.text_dim) }; spans.push(Span::styled( format!( @@ -2766,12 +3542,12 @@ fn draw_planning_status( if !app.planning.error.is_empty() { spans.push(Span::styled( format!(" {}", app.planning.error), - Style::default().fg(Color::Red), + Style::default().fg(t.err), )); } else if app.planning.busy { spans.push(Span::styled( format!(" {} {}", spinner_frame(), app.planning.status), - Style::default().fg(Color::Yellow), + Style::default().fg(t.warn), )); } else if !app.planning.status.is_empty() { // Commit and handoff both land here: they finish by clearing `busy` @@ -2779,12 +3555,12 @@ fn draw_planning_status( // shown because the spinner branch above is the only other reader. spans.push(Span::styled( format!(" {}", app.planning.status), - Style::default().fg(Color::Green), + Style::default().fg(t.ok), )); } else if let Some(p) = crate::planning::store::latest_plan(&thread.id) { spans.push(Span::styled( format!(" {}", contract_home(&p)), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )); } @@ -2795,6 +3571,7 @@ fn draw_planning_status( /// eat the early turns, and in a planning thread those are usually the design /// premises everything downstream rests on. fn draw_planning_overflow(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; let label = app.planning.backend_label(); let limit = app .planning @@ -2816,11 +3593,11 @@ fn draw_planning_overflow(f: &mut Frame<'_>, app: &App, area: Rect) { label, limit / 1000 ), - Style::default().fg(Color::Yellow), + Style::default().fg(t.warn), )), Line::from(Span::styled( " [c] compact [b] switch backend [Esc] dismiss", - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), ]; f.render_widget(Paragraph::new(lines), area); @@ -2833,6 +3610,7 @@ fn draw_planning_overflow(f: &mut Frame<'_>, app: &App, area: Rect) { /// A full overlay rather than a menu row: a local server serves dozens of /// models, and stepping through those one Enter at a time is not choosing. fn draw_orchestrator_model_picker(f: &mut Frame<'_>, app: &App, area: Rect, sel: usize) -> Rect { + let t = &app.theme; let models = &app.orchestrator_models; let height = (models.len() as u16 + 2) .min(area.height.saturating_sub(4)) @@ -2843,13 +3621,13 @@ fn draw_orchestrator_model_picker(f: &mut Frame<'_>, app: &App, area: Rect, sel: let block = Block::default() .title(" Orchestrator model · ↵ select · r reprobe · esc ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(t.info)); if models.is_empty() { f.render_widget( Paragraph::new(Span::styled( " endpoint reported no models", - Style::default().fg(Color::Red), + Style::default().fg(t.err), )) .block(block), popup, @@ -2886,6 +3664,7 @@ fn draw_orchestrator_model_picker(f: &mut Frame<'_>, app: &App, area: Rect, sel: } fn draw_planning_picker(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; let names = app.config.planning.backend_names(); let sel = app.planning.picker.unwrap_or(0); if let Some(msel) = app.planning.picker_model { @@ -2906,13 +3685,13 @@ fn draw_planning_picker(f: &mut Frame<'_>, app: &App, area: Rect) { f.render_widget( Paragraph::new(Span::styled( " no backends — configure [agents.*] or [planning.backends.*]", - Style::default().fg(Color::Red), + Style::default().fg(t.err), )) .block( Block::default() .title(" Backend ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), + .border_style(Style::default().fg(t.info)), ), popup, ); @@ -2951,10 +3730,7 @@ fn draw_planning_picker(f: &mut Frame<'_>, app: &App, area: Rect) { }; ListItem::new(Line::from(vec![ Span::styled(format!(" {:<12}", n), style), - Span::styled( - format!(" {}", detail), - Style::default().fg(Color::DarkGray), - ), + Span::styled(format!(" {}", detail), Style::default().fg(t.text_dim)), ])) }) .collect(); @@ -2964,7 +3740,7 @@ fn draw_planning_picker(f: &mut Frame<'_>, app: &App, area: Rect) { Block::default() .title(" Backend · ↵/→ models · r reprobe ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), + .border_style(Style::default().fg(t.info)), ), popup, ); @@ -2980,6 +3756,7 @@ fn draw_planning_model_picker( backend: String, sel: usize, ) { + let t = &app.theme; let models = app .planning .model_cache @@ -3024,13 +3801,14 @@ fn draw_planning_model_picker( Block::default() .title(format!(" Model · {} · ↵ select · ← back ", backend)) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), + .border_style(Style::default().fg(t.info)), ), popup, ); } fn draw_planning_delete_confirm(f: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; let target = app.planning.confirm_delete.as_ref().and_then(|id| { app.planning .threads @@ -3047,11 +3825,11 @@ fn draw_planning_delete_confirm(f: &mut Frame<'_>, app: &App, area: Rect) { // root is what tells them apart before an irreversible delete. Line::from(Span::styled( format!(" {}", ellipsize(&contract_home(&root), 50)), - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), Line::from(Span::styled( " [y] delete [n/Esc] cancel", - Style::default().fg(Color::DarkGray), + Style::default().fg(t.text_dim), )), ]; f.render_widget( @@ -3059,7 +3837,7 @@ fn draw_planning_delete_confirm(f: &mut Frame<'_>, app: &App, area: Rect) { Block::default() .title(" Confirm ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)), + .border_style(Style::default().fg(t.err)), ), popup, ); @@ -3068,6 +3846,698 @@ fn draw_planning_delete_confirm(f: &mut Frame<'_>, app: &App, area: Rect) { #[cfg(test)] mod tests { use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + /// Render `draw()` into an offscreen buffer and flatten it to rows of + /// `(text, fg)` — a snapshot fine-grained enough to catch a colour + /// regression, coarse enough not to churn on unrelated edits. + fn render_rows(app: &App, width: u16, height: u16) -> Vec<(String, Vec)> { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + (0..height) + .map(|y| { + let mut text = String::new(); + let mut fgs = Vec::new(); + for x in 0..width { + let cell = buffer.get(x, y); + text.push_str(cell.symbol()); + fgs.push(cell.fg); + } + (text.trim_end().to_string(), fgs) + }) + .collect() + } + + /// Config for the rendering tests. The theme base is pinned because + /// `Theme::resolve` otherwise reads `COLORTERM` off the real environment + /// and hands back `ansi16` where truecolor isn't claimed — which is how + /// these assertions pass in a terminal and fail in CI. + fn test_config() -> crate::config::Config { + let mut config = crate::config::Config::default(); + config.theme.base = Some("classic".into()); + config + } + + fn snapshot_app() -> App { + let (tx, _rx) = tokio::sync::mpsc::channel(8); + App::new(tx, std::sync::Arc::new(test_config())) + } + + /// Pins the rendered frame so a restyle has to be a deliberate edit to + /// this expectation rather than a silent change nobody reviewed. Sessions + /// spawned here are headless, so this is the frame's chrome: the tab + /// strip and its rule, the output pane, and the status panel. + #[test] + fn the_empty_frame_renders_its_chrome_in_the_expected_rows() { + let app = snapshot_app(); + let rows = render_rows(&app, 60, 15); + let text: Vec<&str> = rows.iter().map(|(t, _)| t.as_str()).collect(); + + assert_eq!(text[0], "", "no sessions: the strip is blank"); + assert_eq!(text[1], "─".repeat(60)); + assert_eq!(text[2], " ▎ ● linkshell", "rail (empty) then the pane"); + assert_eq!(text[3], " ▎ No sessions. Press alt-n to create one."); + // The pane runs to the footer: no bottom border, and in the default + // overlay mode the status panel claims no rows either. + assert_eq!(text[13], " ▎"); + assert_eq!(text[14], " no session — alt-n to create one"); + + let t = Theme::classic(); + assert_eq!(rows[1].1[0], t.chrome, "the rule is chrome"); + assert_eq!(rows[2].1[3], t.accent, "the focused pane's bar"); + } + + fn app_with_sessions(names: &[&str]) -> App { + let mut app = snapshot_app(); + for name in names { + app.spawn_headless_session((*name).to_string(), None) + .unwrap(); + } + app + } + + #[test] + fn tabs_are_one_row_and_name_every_visible_session() { + let app = app_with_sessions(&["a", "b", "c"]); + let rows = render_rows(&app, 60, 14); + // Headless sessions are SessionKind::Shell, so every tab reads + // "N shell"; what is pinned here is the layout, not the label. + assert_eq!(rows[0].0, " 1 a 2 b 3 c"); + assert_eq!(rows[1].0, "─".repeat(60)); + } + + #[test] + fn a_waiting_session_is_marked_without_the_status_panel() { + let mut app = app_with_sessions(&["a", "b"]); + app.sessions[1].state = SessionState::Waiting; + app.sessions[0].state = SessionState::Error; + let rows = render_rows(&app, 60, 14); + assert_eq!(rows[0].0, " 1 a✕ 2 b!"); + + // The glyphs carry the state colour, not just the shape. + let t = Theme::classic(); + assert_eq!(rows[0].1[4], t.err); + assert_eq!(rows[0].1[10], t.warn); + } + + #[test] + fn the_active_tab_is_reversed_into_the_accent() { + let app = app_with_sessions(&["a", "b"]); + let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let t = Theme::classic(); + // Session 1 is active (spawning claims pane 0). + assert_eq!(buffer.get(1, 0).bg, t.accent); + assert_eq!(buffer.get(1, 0).fg, t.on_accent); + // Session 2 is not. + assert_eq!(buffer.get(10, 0).bg, t.bg); + } + + #[test] + fn four_tabs_fit_in_sixty_columns() { + let app = app_with_sessions(&["a", "b", "c", "d"]); + let rows = render_rows(&app, 60, 14); + assert_eq!(rows[0].0, " 1 a 2 b 3 c 4 d"); + } + + #[test] + fn a_narrow_strip_drops_names_before_it_drops_tabs() { + // 4 × " N review " is 40 columns. At 24 the middle rung applies: the + // active tab keeps its name, the rest fall back to bare indices. + let app = app_with_sessions(&["review", "review", "review", "review"]); + assert_eq!(render_rows(&app, 24, 14)[0].0, " 1 review 2 3 4"); + + // Six sessions leave no room even for that, so every tab drops to + // its index rather than any tab being dropped outright. + let app = app_with_sessions(&["review", "review", "review", "review", "review", "review"]); + assert_eq!(render_rows(&app, 20, 14)[0].0, " 1 2 3 4 5 6"); + } + + /// Click-to-focus reads `session_slot_areas` positionally and maps index + /// i back through `visible_to_idx`, so the strip must return exactly one + /// rect per visible session even when a tab is squeezed to nothing. + #[test] + fn every_visible_session_gets_a_click_rect() { + let app = app_with_sessions(&["a", "b", "c", "d"]); + // MIN_COLS is 20; below it draw() bails to the too-small placeholder + // and deliberately returns no rects at all. + for width in [60u16, 36, 24, 20] { + let mut layout = LayoutInfo::default(); + let mut terminal = Terminal::new(TestBackend::new(width, 14)).unwrap(); + terminal + .draw(|f| { + layout = draw(f, &app); + }) + .unwrap(); + assert_eq!( + layout.session_slot_areas.len(), + 4, + "width {width} returned {} rects", + layout.session_slot_areas.len() + ); + } + } + + #[test] + fn a_click_rect_lands_on_the_tab_it_names() { + let app = app_with_sessions(&["a", "b", "c"]); + let mut layout = LayoutInfo::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap(); + terminal + .draw(|f| { + layout = draw(f, &app); + }) + .unwrap(); + // " 1 a " is 5 wide, so tab 2 owns columns 5..10 on row 0. + let second = layout.session_slot_areas[1]; + assert_eq!( + (second.x, second.y, second.width, second.height), + (5, 0, 5, 1) + ); + } + + // ── Pane chrome ─────────────────────────────────────────────────────── + + /// The hazard the borderless pane introduces: content width is what the + /// PTY is sized to and what mouse selection maps against. `Borders::ALL` + /// gave `width - 2`; the focus bar plus its padding must give the same, + /// or every session silently gets a wider PTY than it renders into. + #[test] + fn dropping_the_border_did_not_change_the_content_width() { + for (w, h) in [(80u16, 24u16), (40, 12), (3, 2), (1, 1), (0, 0)] { + let outer = Rect { + x: 5, + y: 3, + width: w, + height: h, + }; + let content = pane_content_area(outer); + assert_eq!( + content.width, + w.saturating_sub(2), + "width parity with Borders::ALL at {w}x{h}" + ); + // Height gains a row: the bottom border is gone, the title is not. + assert_eq!(content.height, h.saturating_sub(1), "at {w}x{h}"); + if w >= 2 && h >= 1 { + assert!( + content.x + content.width <= outer.x + outer.width, + "content overflows the pane at {w}x{h}" + ); + assert!(content.y + content.height <= outer.y + outer.height); + } + } + } + + /// The renderer, the PTY size and the mouse mapping all have to agree. + /// They used to be three independent `saturating_sub(2)`s. + #[test] + fn the_pty_size_matches_the_rendered_content_area() { + let app = app_with_sessions(&["alpha"]); + let layout = layout_of(&app, 80, 30); + let area = layout.output_areas[0]; + let content = pane_content_area(area); + // This is the arithmetic main.rs performs to size the PTY. + assert_eq!( + (content.height.max(1), content.width.max(1)), + (area.height - 1, area.width - 2) + ); + } + + #[test] + fn the_focused_pane_is_marked_and_its_neighbour_divides_from_it() { + let t = Theme::classic(); + let mut app = app_with_sessions(&["alpha", "beta"]); + app.split_focused(crate::layout::SplitDir::Row); + let layout = layout_of(&app, 80, 30); + assert_eq!(layout.output_areas.len(), 2, "expected a split"); + + let mut terminal = Terminal::new(TestBackend::new(80, 30)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + + for (i, area) in layout.output_areas.iter().enumerate() { + let cell = buffer.get(area.x, area.y + 1); + if i == app.focused_pane { + assert_eq!(cell.symbol(), "▎", "focused pane {i}"); + assert_eq!(cell.fg, t.accent); + } else { + // The same column separates the panes when it isn't marking + // focus — one column doing both jobs, so a split needs no + // per-pane boxes and no separate divider. + assert_eq!(cell.symbol(), "│", "unfocused pane {i}"); + assert_eq!(cell.fg, t.chrome); + } + } + } + + #[test] + fn a_pane_title_names_the_session_and_its_directory() { + let mut app = app_with_sessions(&["alpha"]); + app.sessions[0].cwd = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) + "/src"; + let rows = render_rows(&app, 60, 15); + assert_eq!(rows[2].0, "●1 ▎ ● alpha · ~/src", "rail, then the pane"); + } + + // ── Status panel ────────────────────────────────────────────────────── + + /// The regression this whole mode exists to avoid. A docked panel takes + /// rows from the output pane, so toggling it resizes every PTY and makes + /// every full-screen agent repaint. The overlay is drawn *over* the + /// output; the pane rects must be identical open and closed. + #[test] + fn opening_the_overlay_does_not_resize_any_pane() { + let mut app = placed_app("overlay", &["alpha", "beta"]); + let closed = layout_of(&app, 80, 30).output_areas; + app.toggle_status_panel(); + assert!(matches!(app.mode, AppMode::Status)); + let open = layout_of(&app, 80, 30).output_areas; + assert_eq!(closed, open); + } + + fn layout_of(app: &App, width: u16, height: u16) -> LayoutInfo { + let mut layout = LayoutInfo::default(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| { + layout = draw(f, app); + }) + .unwrap(); + layout + } + + /// An app with the status panel placed explicitly, rather than at the + /// `left` default. + fn placed_app(placement: &str, names: &[&str]) -> App { + let mut config = test_config(); + config.general.status_panel = placement.into(); + let (tx, _rx) = tokio::sync::mpsc::channel(8); + let mut app = App::new(tx, std::sync::Arc::new(config)); + for name in names { + app.spawn_headless_session((*name).to_string(), None) + .unwrap(); + } + app + } + + fn docked_app(names: &[&str]) -> App { + placed_app("bottom", names) + } + + #[test] + fn overlay_mode_gives_its_rows_back_to_the_output_pane() { + let overlay = placed_app("overlay", &["alpha", "beta"]); + let docked = docked_app(&["alpha", "beta"]); + let overlay_h = layout_of(&overlay, 80, 30).output_areas[0].height; + let docked_h = layout_of(&docked, 80, 30).output_areas[0].height; + assert!( + overlay_h > docked_h, + "overlay {overlay_h} should beat docked {docked_h}" + ); + } + + #[test] + fn a_docked_panel_is_always_on_and_alt_s_does_not_open_a_second_one() { + let mut app = docked_app(&["alpha"]); + let rows: Vec = render_rows(&app, 80, 30) + .into_iter() + .map(|(t, _)| t) + .collect(); + assert!(rows.iter().any(|r| r.contains("Status")), "{rows:#?}"); + + app.toggle_status_panel(); + assert!(matches!(app.mode, AppMode::Normal), "alt-s is a no-op"); + } + + #[test] + fn the_overlay_shows_two_lines_per_session_with_model_and_cwd() { + let mut app = placed_app("overlay", &["alpha"]); + app.sessions[0].model = Some("claude-opus-4-8".into()); + app.sessions[0].cwd = "/tmp/work".into(); + app.mode = AppMode::Status; + let rows: Vec = render_rows(&app, 80, 30) + .into_iter() + .map(|(t, _)| t) + .collect(); + let body = rows.join("\n"); + assert!(body.contains("alpha"), "{body}"); + assert!(body.contains("opus-4-8 · /tmp/work"), "{body}"); + assert!(body.contains("1 session"), "totals row: {body}"); + assert!(body.contains("esc to close"), "{body}"); + // No column separators survived the rewrite. + assert!(!body.contains("│ Kind"), "{body}"); + } + + #[test] + fn the_detail_line_names_pipe_peers_in_both_directions() { + let mut app = placed_app("overlay", &["alpha", "beta", "gamma"]); + for (source, dest) in [(0, 1), (2, 0)] { + app.pipes.push(crate::pipe::Pipe { + source, + dest, + trigger: crate::pipe::PipeTrigger::Manual, + extract: crate::pipe::ExtractMode::LastBlock, + prefix: None, + active: true, + last_fired: None, + condition: None, + }); + } + app.mode = AppMode::Status; + let body = render_rows(&app, 80, 30) + .into_iter() + .map(|(t, _)| t) + .collect::>() + .join("\n"); + assert!(body.contains("→ beta"), "outgoing: {body}"); + assert!(body.contains("← gamma"), "incoming: {body}"); + } + + /// Two-line rows double what a docked panel costs, and the height/3 cap + /// reaches that sooner. Dropping the detail line beats showing half the + /// sessions — the overlay still has the detail, one keystroke away. + #[test] + fn a_crowded_docked_panel_drops_the_detail_line_not_the_sessions() { + let app = docked_app(&["a", "b", "c", "d", "e"]); + let layout = layout_of(&app, 80, 40); + assert_eq!(layout.status_row_areas.len(), 5); + for pair in layout.status_row_areas.windows(2) { + assert_eq!(pair[1].y - pair[0].y, 1, "rows should be one line each"); + } + + let app = docked_app(&["a", "b", "c", "d"]); + let layout = layout_of(&app, 80, 40); + assert_eq!(layout.status_row_areas.len(), 4); + for pair in layout.status_row_areas.windows(2) { + assert_eq!(pair[1].y - pair[0].y, 2, "rows should be two lines each"); + } + } + + #[test] + fn click_to_focus_works_in_both_modes() { + for mut app in [ + placed_app("overlay", &["alpha", "beta"]), + docked_app(&["alpha", "beta"]), + app_with_sessions(&["alpha", "beta"]), + ] { + if !app.status_docked() { + app.mode = AppMode::Status; + } + let layout = layout_of(&app, 120, 30); + assert_eq!(layout.status_row_areas.len(), 2); + let second = layout.status_row_areas[1]; + assert!(second.height == 1 && second.width > 0); + } + } + + // ── Scrollback ──────────────────────────────────────────────────────── + + /// Scrolled fully to the top, `total - history_scroll` reaches 0 and the + /// window collapsed to nothing — the scrollback went blank exactly when + /// you reached the beginning of it. + #[test] + fn the_oldest_page_of_scrollback_is_not_blank() { + let mut app = app_with_sessions(&["cx"]); + { + let s = &mut app.sessions[0]; + s.kind = crate::session::SessionKind::Codex; + for i in 0..40 { + s.push_scrollback_line(format!("line-{i}")); + } + } + // main.rs syncs pane_sizes from the drawn layout; do it by hand here + // so the clamp knows how tall the visible page is. + app.pane_sizes[0] = (12, 78); + app.scroll_up(10_000); + let body = render_rows(&app, 80, 20) + .into_iter() + .map(|(t, _)| t) + .collect::>() + .join("\n"); + assert!( + body.contains("line-0"), + "the oldest page should show: {body}" + ); + assert!(body.contains("↑"), "and say it is scrolled: {body}"); + } + + // ── Status sidebar ──────────────────────────────────────────────────── + + #[test] + fn the_sidebar_is_on_by_default_and_stacks_each_session() { + let mut app = app_with_sessions(&["alpha", "beta"]); + app.sessions[0].model = Some("claude-opus-4-8".into()); + app.sessions[0].stats.context_tokens = 18_000; + app.sessions[0].context_max = 180_000; + let rows: Vec = render_rows(&app, 120, 30) + .into_iter() + .map(|(t, _)| t) + .collect(); + let body = rows.join("\n"); + assert!(body.contains("alpha"), "{body}"); + assert!(body.contains("READY"), "{body}"); + assert!(body.contains("opus-4-8"), "{body}"); + assert!(body.contains("18.0k/180.0k"), "{body}"); + assert!(body.contains("2 sess"), "totals: {body}"); + } + + /// The whole reason the sidebar beats the bottom region: it has the full + /// column height, so it does not hit a height/3 cap at four sessions. + #[test] + fn the_sidebar_holds_every_session_where_the_bottom_region_could_not() { + // 24 rows: the bottom region's height/3 cap leaves it 6 usable rows, + // the sidebar has the whole column. + let app = app_with_sessions(&["a", "b", "c", "d", "e", "f", "g", "h"]); + let layout = layout_of(&app, 120, 24); + assert_eq!(layout.status_row_areas.len(), 8); + + let bottom = docked_app(&["a", "b", "c", "d", "e", "f", "g", "h"]); + let bottom_rows = layout_of(&bottom, 120, 24).status_row_areas.len(); + assert!( + bottom_rows < 8, + "the bottom region was expected to run out of rows, showed {bottom_rows}" + ); + } + + /// "Permanently there" has to survive a narrow terminal, and the panel's + /// job — which agent needs you — still fits in three columns. + #[test] + fn a_narrow_terminal_collapses_the_sidebar_to_a_rail() { + let mut app = app_with_sessions(&["alpha", "beta", "gamma"]); + app.sessions[1].state = SessionState::Waiting; + app.sessions[2].state = SessionState::Error; + + assert_eq!(sidebar_width(&app, 120), Some(28), "wide: full sidebar"); + assert_eq!( + sidebar_width(&app, 80), + Some(SIDEBAR_RAIL_COLS), + "narrow: rail" + ); + // The threshold is where the output pane would drop under 60 columns. + assert_eq!(sidebar_width(&app, 88), Some(28)); + assert_eq!(sidebar_width(&app, 87), Some(SIDEBAR_RAIL_COLS)); + + let rows: Vec = render_rows(&app, 80, 20) + .into_iter() + .map(|(t, _)| t) + .collect(); + // State still reaches you: the marker glyph survives the collapse. + let rail = |row: &str| row.chars().take(2).collect::(); + assert_eq!(rail(&rows[2]), "●1"); + assert_eq!(rail(&rows[3]), "!2"); + assert_eq!(rail(&rows[4]), "✕3"); + } + + #[test] + fn alt_s_hides_and_restores_a_docked_panel() { + for placement in ["left", "bottom"] { + let mut app = placed_app(placement, &["alpha"]); + let shown = layout_of(&app, 120, 30).output_areas[0]; + + app.toggle_status_panel(); + assert!(app.status_hidden, "{placement}"); + let hidden = layout_of(&app, 120, 30).output_areas[0]; + assert!( + hidden.width > shown.width || hidden.height > shown.height, + "{placement}: hiding should give the space back" + ); + + app.toggle_status_panel(); + assert!(!app.status_hidden); + assert_eq!(layout_of(&app, 120, 30).output_areas[0], shown); + } + } + + #[test] + fn status_panel_off_never_renders_and_alt_s_does_nothing() { + let mut app = placed_app("off", &["alpha"]); + let body = render_rows(&app, 120, 30) + .into_iter() + .map(|(t, _)| t) + .collect::>() + .join("\n"); + assert!(!body.contains("Status"), "{body}"); + assert!(!body.contains("sess"), "{body}"); + + app.toggle_status_panel(); + assert!(matches!(app.mode, AppMode::Normal)); + assert!(!app.status_hidden); + } + + #[test] + fn an_unknown_placement_falls_back_to_the_default() { + let app = placed_app("sideways", &["alpha"]); + assert_eq!(app.status_placement(), StatusPlacement::Left); + } + + /// The sidebar takes columns from the output, so the PTY must be sized + /// to what is left — the same three-way agreement the pane chrome has. + #[test] + fn the_sidebar_narrows_the_pty_by_exactly_its_width() { + let with = app_with_sessions(&["alpha"]); + let without = placed_app("off", &["alpha"]); + let a = pane_content_area(layout_of(&with, 120, 30).output_areas[0]); + let b = pane_content_area(layout_of(&without, 120, 30).output_areas[0]); + assert_eq!(b.width - a.width, 28); + assert_eq!(a.height, b.height, "the sidebar costs no rows"); + } + + // ── Footer ──────────────────────────────────────────────────────────── + + fn footer_row(app: &App, width: u16) -> (String, Vec) { + let height = 15; + render_rows(app, width, height).pop().unwrap() + } + + #[test] + fn the_footer_carries_the_active_session_name_state_and_elapsed() { + let mut app = app_with_sessions(&["alpha", "beta"]); + app.sessions[0].model = Some("claude-opus-4-8-20260101".into()); + let (text, _) = footer_row(&app, 80); + assert!(text.starts_with(" alpha · opus-4-8"), "{text:?}"); + assert!(text.contains("READY"), "{text:?}"); + assert!(text.contains("alt-h help"), "{text:?}"); + } + + #[test] + fn the_context_counter_turns_amber_past_eighty_percent() { + let t = Theme::classic(); + let mut app = app_with_sessions(&["alpha"]); + app.sessions[0].context_max = 100_000; + + app.sessions[0].stats.context_tokens = 79_000; + let (text, fgs) = footer_row(&app, 80); + let at = text.find("79.0k").expect(&text); + assert_eq!(fgs[at], t.ctx, "below the threshold: {text:?}"); + + app.sessions[0].stats.context_tokens = 80_000; + let (text, fgs) = footer_row(&app, 80); + let at = text.find("80.0k").expect(&text); + assert_eq!(fgs[at], t.warn, "at the threshold: {text:?}"); + } + + /// A local model whose window was never probed has no denominator, so + /// there is no percentage to colour against. Rendering a bare count beats + /// inventing a threshold. + #[test] + fn an_unknown_context_window_renders_a_bare_count() { + let t = Theme::classic(); + let mut app = app_with_sessions(&["alpha"]); + app.sessions[0].stats.context_tokens = 90_000; + app.sessions[0].context_max = 0; + let (text, fgs) = footer_row(&app, 80); + assert!(text.contains("90.0k"), "{text:?}"); + assert!(!text.contains("90.0k/"), "no denominator: {text:?}"); + assert_eq!(fgs[text.find("90.0k").unwrap()], t.ctx); + } + + /// A wrapped footer would silently eat a row of output. Every field is + /// droppable; the session's identity is what survives. + #[test] + fn the_footer_never_wraps_however_narrow_the_terminal() { + let mut app = app_with_sessions(&["a-long-session-name"]); + app.sessions[0].model = Some("claude-opus-4-8".into()); + app.sessions[0].stats.context_tokens = 90_000; + app.sessions[0].stats.total_cost_usd = 1.25; + app.sessions[0].context_max = 100_000; + for width in MIN_COLS..100 { + let rows = render_rows(&app, width, 15); + assert_eq!(rows.len(), 15, "width {width} changed the row count"); + let (text, _) = &rows[14]; + assert!( + text.chars().count() <= width as usize, + "width {width} overflowed: {text:?}" + ); + } + } + + #[test] + fn the_hint_is_shed_before_the_cost_and_the_cost_before_the_state() { + let mut app = app_with_sessions(&["alpha"]); + app.sessions[0].stats.total_cost_usd = 1.25; + assert!(footer_row(&app, 80).0.contains("alt-h help")); + + let (text, _) = footer_row(&app, 34); + assert!( + !text.contains("alt-h help"), + "hint should go first: {text:?}" + ); + assert!(text.contains("$1.250"), "{text:?}"); + + let (text, _) = footer_row(&app, 26); + assert!(!text.contains("$1.250"), "cost should go next: {text:?}"); + assert!(text.contains("READY"), "state outlives cost: {text:?}"); + + // Whatever else goes, the session is still named. + assert!(footer_row(&app, MIN_COLS).0.contains("alpha")); + } + + #[test] + fn a_dead_session_says_how_to_bring_it_back_and_greys_out() { + let t = Theme::classic(); + let mut app = app_with_sessions(&["alpha"]); + app.sessions[0].state = SessionState::Dead; + let (text, fgs) = footer_row(&app, 80); + assert!(text.contains("alt-r restart"), "{text:?}"); + // Inert: the whole row is dim, including the name. + assert_eq!(fgs[1], t.text_dim); + } + + #[test] + fn broadcast_pushes_the_tabs_right_instead_of_covering_them() { + let mut app = app_with_sessions(&["a"]); + app.broadcast_mode = true; + let rows = render_rows(&app, 60, 14); + assert_eq!(rows[0].0, " BROADCAST 1 a"); + } + + #[test] + fn a_theme_override_reaches_the_rendered_frame() { + let mut config = crate::config::Config::default(); + config.theme.base = Some("classic".into()); + config.theme.chrome = Some("#ff00ff".into()); + let (tx, _rx) = tokio::sync::mpsc::channel(8); + let app = App::new(tx, std::sync::Arc::new(config)); + + let rows = render_rows(&app, 60, 15); + // The rule under the tab strip is drawn in chrome. + assert_eq!(rows[1].1[0], Color::Rgb(0xff, 0, 0xff)); + } /// `used / 1000` rendered every thread under 1000 tokens as "0k", which /// reads as a meter that is not measuring anything. @@ -3126,6 +4596,7 @@ mod tests { #[test] fn build_row_skips_wide_continuation_cells() { + let t = Theme::classic(); // A double-width glyph occupies two vt100 cells; the second is a // continuation marker. It must not render as an extra space, or every // wide char shifts the rest of the line right by one column. @@ -3135,15 +4606,16 @@ mod tests { assert!(screen.cell(0, 0).unwrap().is_wide()); assert!(screen.cell(0, 1).unwrap().is_wide_continuation()); - let line = build_row_line(screen, 0, 20, 0, None, None); + let line = build_row_line(&t, screen, 0, 20, 0, None, None); assert_eq!(line_text(&line), "\u{4e16}x plain"); } #[test] fn build_row_renders_plain_ascii_unchanged() { + let t = Theme::classic(); let mut parser = vt100::Parser::new(2, 20, 0); parser.process(b"hello world"); - let line = build_row_line(parser.screen(), 0, 20, 0, None, None); + let line = build_row_line(&t, parser.screen(), 0, 20, 0, None, None); assert_eq!(line_text(&line), "hello world"); } @@ -3185,42 +4657,27 @@ mod tests { #[test] fn kind_color_assigns_distinct_brand_colors() { - assert_eq!(kind_color(&SessionKind::Claude), CLAUDE_COLOR); - assert_eq!(kind_color(&SessionKind::Codex), CODEX_COLOR); - assert_eq!(kind_color(&SessionKind::OpenCode), OPENCODE_COLOR); - assert_eq!(kind_color(&SessionKind::OhMyPi), OHMYPI_COLOR); - assert_eq!(kind_color(&SessionKind::Aider), AIDER_COLOR); - assert_eq!(kind_color(&SessionKind::Shell), SHELL_COLOR); - assert_eq!(kind_color(&SessionKind::Custom("x".into())), CUSTOM_COLOR); - } - - #[test] - fn state_border_style_highlights_waiting_error_and_active_states() { - assert_eq!( - state_border_style(&SessionState::Waiting, false).fg, - Some(Color::Yellow) - ); - assert_eq!( - state_border_style(&SessionState::Error, false).fg, - Some(Color::Red) - ); - assert_eq!( - state_border_style(&SessionState::Ready, true).fg, - Some(Color::White) - ); + let t = Theme::classic(); + assert_eq!(kind_color(&t, &SessionKind::Claude), t.kind_claude); + assert_eq!(kind_color(&t, &SessionKind::Codex), t.kind_codex); + assert_eq!(kind_color(&t, &SessionKind::OpenCode), t.kind_opencode); + assert_eq!(kind_color(&t, &SessionKind::OhMyPi), t.kind_ohmypi); + assert_eq!(kind_color(&t, &SessionKind::Aider), t.kind_aider); + assert_eq!(kind_color(&t, &SessionKind::Shell), t.kind_shell); assert_eq!( - state_border_style(&SessionState::Ready, false).fg, - Some(Color::DarkGray) + kind_color(&t, &SessionKind::Custom("x".into())), + t.kind_custom ); } #[test] fn style_preserves_spaces_for_background_or_reverse_styles_only() { - assert!(style_preserves_spaces(Style::default().bg(Color::Blue))); + let t = Theme::classic(); + assert!(style_preserves_spaces(Style::default().bg(t.sel_bg))); assert!(style_preserves_spaces( Style::default().add_modifier(Modifier::REVERSED) )); - assert!(!style_preserves_spaces(Style::default().fg(Color::Green))); + assert!(!style_preserves_spaces(Style::default().fg(t.ok))); } #[test]