From fe1b3a06f6f9a5e9cd1442ce1fbf6eacd837d406 Mon Sep 17 00:00:00 2001 From: David Budac Date: Sun, 4 Oct 2026 15:47:44 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Settings=20=E2=80=BA=20Appearance=20?= =?UTF-8?q?=E2=80=BA=20Layout=20brings=20the=20three=20panels=20back:=20PR?= =?UTF-8?q?OJECTS,=20WORKTREES=20and=20SESSIONS=20columns=20beside=20the?= =?UTF-8?q?=20pane,=20on=20the=20grid's=20own=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New config key `layout`: `grid` (the default, unchanged) or `panels`. The row cycles live; switching keeps the selected project, worktree and session and whatever the pane has attached. - `panels` draws the three columns from before the grid (ui/panels_view.rs, model in panels.rs) on the cursors the grid already indexes, so there is no second selection. The PROJECTS column stands in for the PROJECT TABS. - Keys: event_loop/panels.rs gives the grid-owned keys their panel meaning (`Space` follow-up, `^F` full screen, `]`/`[` attention walk, `⇧A` folds ARCHIVED) or a line saying they have none here. Rows and group headers take clicks; the wheel scrolls a column without moving its cursor. - `?` with the PANELS up lists the panels' own keys; the grid's help is unchanged. - docs/configuration.md, docs/keys.md and docs/sessions.md follow. Refs #108 Co-Authored-By: Claude Opus 5.5 --- crates/nebula-tui/src/app.rs | 40 +- crates/nebula-tui/src/config.rs | 65 ++ crates/nebula-tui/src/event_loop.rs | 23 +- .../nebula-tui/src/event_loop/focus_walk.rs | 1 + crates/nebula-tui/src/event_loop/launcher.rs | 5 +- crates/nebula-tui/src/event_loop/panels.rs | 779 ++++++++++++++++++ crates/nebula-tui/src/lib.rs | 1 + crates/nebula-tui/src/panels.rs | 446 ++++++++++ crates/nebula-tui/src/ui.rs | 47 +- crates/nebula-tui/src/ui/panels_view.rs | 610 ++++++++++++++ crates/nebula/tests/e2e_tui.rs | 185 +++++ docs/configuration.md | 3 +- docs/keys.md | 23 + docs/sessions.md | 22 + 14 files changed, 2230 insertions(+), 20 deletions(-) create mode 100644 crates/nebula-tui/src/event_loop/panels.rs create mode 100644 crates/nebula-tui/src/panels.rs create mode 100644 crates/nebula-tui/src/ui/panels_view.rs diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 6c8e5be3..31b30cd1 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -67,8 +67,14 @@ pub enum Focus { /// What a screen cell maps to; rebuilt on every draw for hit-testing. #[derive(Debug, Clone, PartialEq)] pub enum HitTarget { - /// The GRID's background (registered after the cards, so they win). + /// The GRID's background (registered after the cards, so they win), + /// or a PANELS column's. PanelBg(Focus), + /// A row of the PANELS — a project, a checkout (or pull request, or + /// issue), a session — or a group header there that folds + /// (`crate::panels::Row`). Registered ahead of its column's + /// `PanelBg`, so it wins. + PanelsRow(crate::panels::Row), TerminalPane, /// The session URL on the CLOUD SESSION PANEL; a click opens it in the /// browser. Registered ahead of the pane it sits on, so it wins. @@ -3286,6 +3292,19 @@ pub struct App { /// (`event_loop::apply_config`). What a frame lays out is /// [`App::panel_layout`]. pub launcher_list: bool, + /// The body is the PANELS — PROJECTS | WORKTREES | SESSIONS beside the + /// pane — rather than the PROJECT TABS over the GRID: Settings → + /// Appearance → **Layout** (`event_loop::apply_config`). Only a + /// switch: both draw the one selection (`sel_project`, `sel_worktree`, + /// `sel_session`) and the one pane, so flipping it mid-session lands + /// on the same rows with the same session attached. What a frame + /// draws is [`App::panels_active`]. + pub panels: bool, + /// The PANELS' columns' scroll — PROJECTS, WORKTREES, SESSIONS, in + /// that order ([`crate::panels::ColumnScroll`]): the wheel moves a + /// column under its cursor, which stays put, and a move of the + /// cursor brings it back on screen. Drawn by `ui::panels_view`. + pub panels_scroll: [crate::panels::ColumnScroll; 3], /// Every BAND is laid out open at once — its cards wrapped into rows, /// or every entry of the LIST listed — and there is no ACCORDION: /// Settings → Appearance → **Expand all worktrees** @@ -3859,6 +3878,8 @@ impl App { launcher_pane_w: None, launcher_pane_at: crate::launcher::PaneSide::default(), launcher_list: false, + panels: false, + panels_scroll: Default::default(), launcher_all_open: false, launcher_pane_hidden: false, launcher_expanded: None, @@ -4142,7 +4163,17 @@ impl App { /// no session has been opened full-screen over it (`collapsed`, which /// `ui::draw` hands to the pane before it ever reaches the view). pub fn launcher_grid(&self) -> bool { - self.launcher_active() && !self.collapsed + self.launcher_active() && !self.collapsed && !self.panels + } + + /// The PANELS are what the body draws in place of the GRID: the + /// **Layout** setting says so ([`App::panels`]) and there is a project + /// open to draw them for — the same gate as the LAUNCHER VIEW's, so + /// the first run and the all-tabs-closed SPLASH come first either way. + /// True under a full-screen session too, as [`App::launcher_active`] + /// is: the panels are what `^q` comes back down to. + pub fn panels_active(&self) -> bool { + self.panels && self.launcher_active() } /// Take the keyboard back from the session in the PANE, and say so @@ -5471,9 +5502,10 @@ impl App { /// The two places FOCUS can rest: the LAUNCHER VIEW's GRID of cards /// and the PANE under them. The three columns the other variants name - /// are no longer drawn. + /// are drawn only by the PANELS ([`App::panels_active`]), where all + /// four are places to rest. pub fn focus_visible(&self, focus: Focus) -> bool { - matches!(focus, Focus::Sessions | Focus::Terminal) + matches!(focus, Focus::Sessions | Focus::Terminal) || self.panels_active() } fn focus_rank(focus: Focus) -> u8 { diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index 37f460a0..4bd67928 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -43,6 +43,13 @@ pub const PANE_SIDES: &[&str] = &[ /// ([`crate::launcher::LIST_RECENT`]). pub const WORKTREE_LAYOUTS: &[&str] = &["cards", "list"]; +/// The **Layout** choices (Settings → Appearance), in the order the row +/// cycles them: the GRID — PROJECT TABS over the lit project's session +/// cards, the default — then the PANELS, the three columns nebula drew +/// before the grid (PROJECTS | WORKTREES | SESSIONS beside the pane, +/// `crate::panels`). +pub const LAYOUTS: &[&str] = &["grid", "panels"]; + /// The **Preset text** choices (Settings → Sessions), in the order the row /// cycles them: the [`PresetText`] sides by label. pub const PRESET_TEXTS: &[&str] = &[ @@ -397,6 +404,7 @@ pub enum SettingKind { Theme, Animations, BlackBackground, + Layout, HideCardMarks, HighlightCurrentCard, SessionPane, @@ -503,6 +511,7 @@ impl SettingKind { | SettingKind::CardIssueNumber => (2026, 9, 24), SettingKind::ExpandAllWorktrees | SettingKind::FollowNewSession => (2026, 9, 26), SettingKind::HighlightCurrentCard => (2026, 9, 28), + SettingKind::Layout => (2026, 10, 3), } } @@ -652,6 +661,12 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ hint: "Paint the window pure black instead of the terminal's own background (off keeps the terminal's, transparency included)", group: "", }, + SettingSpec { + kind: SettingKind::Layout, + label: "Layout", + hint: "The grid of session cards under project tabs, or the three panels — projects, worktrees, sessions — beside the session pane", + group: "", + }, SettingSpec { kind: SettingKind::SessionPane, label: "Session pane", @@ -1074,6 +1089,12 @@ pub struct Config { /// recent shown until Tab — the ACCORDION — opens the rest). Read /// through [`Config::list_layout`], so a word off the list is the cards. pub worktree_layout: String, + /// What the body draws: `grid` (the LAUNCHER VIEW — PROJECT TABS over + /// the GRID of cards, the default) or `panels` (the PROJECTS, + /// WORKTREES and SESSIONS columns beside the pane, `crate::panels`). + /// Read through [`Config::panels_layout`], so a word off the list is + /// the grid. + pub layout: String, /// EXPAND ALL WORKTREES: every BAND on the GRID laid out open at once /// — each worktree's sessions and terminals wrapped into rows under /// its rule, every entry of the compact LIST listed — so there is no @@ -1425,6 +1446,7 @@ impl Default for Config { highlight_current_card: true, session_pane: crate::launcher::PaneSide::default().as_str().into(), worktree_layout: WORKTREE_LAYOUTS[0].into(), + layout: LAYOUTS[0].into(), expand_all_worktrees: false, hide_card_prompt: false, card_issue_number: true, @@ -1603,6 +1625,11 @@ impl Config { self.worktree_layout.trim().eq_ignore_ascii_case("list") } + /// `layout` says the PANELS rather than the grid. + pub fn panels_layout(&self) -> bool { + self.layout.trim().eq_ignore_ascii_case("panels") + } + /// The editor the file overlays launch: `NEBULA_EDITOR` when set, /// otherwise the `editor` setting, otherwise vim. pub fn editor_command(&self) -> String { @@ -2234,6 +2261,7 @@ impl Config { SettingKind::HighlightCurrentCard => on_off(self.highlight_current_card).into(), SettingKind::SessionPane => self.pane_side().as_str().into(), SettingKind::WorktreeLayout => WORKTREE_LAYOUTS[usize::from(self.list_layout())].into(), + SettingKind::Layout => LAYOUTS[usize::from(self.panels_layout())].into(), SettingKind::ExpandAllWorktrees => on_off(self.expand_all_worktrees).into(), SettingKind::CardIssueNumber => on_off(self.card_issue_number).into(), SettingKind::HideDraftPrs => shown_hidden(self.hide_draft_prs).into(), @@ -2344,6 +2372,10 @@ impl Config { let now = WORKTREE_LAYOUTS[usize::from(self.list_layout())]; self.worktree_layout = cycle_choice(now, WORKTREE_LAYOUTS, step).into(); } + SettingKind::Layout => { + let now = LAYOUTS[usize::from(self.panels_layout())]; + self.layout = cycle_choice(now, LAYOUTS, step).into(); + } SettingKind::ExpandAllWorktrees => { self.expand_all_worktrees = !self.expand_all_worktrees; } @@ -3896,6 +3928,39 @@ mod tests { assert!(!odd.list_layout(), "a word off the list"); } + /// The **Layout**: the grid out of the box, cycled from its Appearance + /// row to the PANELS and back, persisted under `layout`. A config + /// predating the key, or holding a word off the list, reads as the + /// grid. + #[test] + fn layout_defaults_to_the_grid_cycles_and_persists() { + let mut cfg = Config::default(); + assert!(!cfg.panels_layout()); + let (tab, row) = locate(SettingKind::Layout).unwrap(); + assert_eq!(SETTINGS_TABS[tab].title, "Appearance"); + assert_eq!(cfg.value_label(SettingKind::Layout), "grid"); + cfg.cycle(tab, row, 1); + assert!(cfg.panels_layout()); + assert_eq!(cfg.value_label(SettingKind::Layout), "panels"); + cfg.cycle(tab, row, -1); + assert!(!cfg.panels_layout(), "and back"); + cfg.cycle(tab, row, 0); + assert!(cfg.panels_layout(), "Enter steps it on too"); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + cfg.save_to(&path).unwrap(); + assert!(load_from(&path).panels_layout()); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(raw.get("layout"), Some(&serde_json::json!("panels"))); + + let older: Config = serde_json::from_str("{}").unwrap(); + assert!(!older.panels_layout(), "predating the key"); + let odd: Config = serde_json::from_str(r#"{"layout": "cards"}"#).unwrap(); + assert!(!odd.panels_layout(), "a word off the list"); + } + /// The QUICK PROMPT's focus toggle: off unless the user turns it on, /// and persisted under its own key (a missed `obj.insert` would let the /// row toggle on screen and read back off on the next launch). diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 4f16747d..a514a1a7 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -35,6 +35,7 @@ mod host_terminal; mod launcher; mod optimistic; mod pacing; +mod panels; mod placeholder; mod quick_launch; mod release_watch; @@ -3140,6 +3141,11 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { if app.launcher_grid() && launcher::handle_action(app, action, armed, &chord, out) { return; } + // The PANELS take the grid's own keys that mean something else — or + // nothing — beside the columns; the rest keep their panel meaning. + if app.panels_active() && !app.collapsed && panels::handle_action(app, action, out) { + return; + } use crate::keymap::Action; match action { Action::Quit => app.overlay = Some(Overlay::Confirm(confirm_quit())), @@ -4585,8 +4591,13 @@ fn confirm_archive_agent(name: &str, id: AgentId) -> ConfirmDialog { /// /// INPUT PARITY: the key reaches `launcher::toggle_archived` through the /// grid's own `handle_action`; the menu and everything else reaches it -/// here, so both ends land the cursor the same way. +/// here, so both ends land the cursor the same way. The PANELS fold their +/// SESSIONS column's ARCHIVED group in place instead. fn toggle_archived(app: &mut App, out: &mut Vec) { + if app.panels_active() { + panels::toggle_archived(app, out); + return; + } launcher::toggle_archived(app, out); } @@ -5395,6 +5406,7 @@ fn select_clicked_row(app: &mut App, target: &HitTarget, out: &mut Vec launcher::select_band_of(app, wid, out), HitTarget::LauncherCardIssue(ref id) => launcher::select_issue_card(app, id, out), + HitTarget::PanelsRow(row) => panels::select_row(app, row, out), _ => false, } } @@ -6447,6 +6459,7 @@ fn apply_config(app: &mut App, cfg: &crate::config::Config) { app.highlight_current_card = cfg.highlight_current_card; app.launcher_pane_at = cfg.pane_side(); app.launcher_list = cfg.list_layout(); + app.panels = cfg.panels_layout(); app.launcher_all_open = cfg.expand_all_worktrees; set_hide_draft_prs(app, cfg.hide_draft_prs); } @@ -9638,6 +9651,10 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // A BAND's rule: the cursor lands on the band, as `j`/`k` // walking onto it do. Some(HitTarget::LauncherBand(i)) => launcher::click_band(app, i, out), + // A PANELS row: the cursor lands on it, its column takes + // FOCUS; a second click is Enter on it. A group header + // folds its group. + Some(HitTarget::PanelsRow(row)) => panels::click_row(app, row, out), // The `❮` / `❯` beside a band's row: one card that way // along the band, the very step `h` / `l` take. Some(HitTarget::LauncherStripLeft(i)) => { @@ -9853,6 +9870,10 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) launcher::wheel_grid(app, up); return; } + // The PANELS' columns: a notch scrolls the column under it. + if app.panels_active() && !app.collapsed && panels::wheel(app, over.as_ref(), up) { + return; + } let in_term = matches!(over, Some(HitTarget::TerminalPane)) || app.collapsed; if in_term && app.reading_url().is_some() { // The pane is showing a pull request or an issue, not a diff --git a/crates/nebula-tui/src/event_loop/focus_walk.rs b/crates/nebula-tui/src/event_loop/focus_walk.rs index 4c154a0c..173032f7 100644 --- a/crates/nebula-tui/src/event_loop/focus_walk.rs +++ b/crates/nebula-tui/src/event_loop/focus_walk.rs @@ -102,6 +102,7 @@ pub(super) fn land_click_focus(app: &mut App, column: u16, row: u16, out: &mut V | HitTarget::LauncherBandMore(_), ) => app.focus = Focus::Sessions, Some(HitTarget::PanelBg(focus)) => app.focus = focus, + Some(HitTarget::PanelsRow(row)) => app.focus = row.focus(), Some(HitTarget::TerminalPane | HitTarget::CloudSessionLink) => { enter_terminal_pane(app, out) } diff --git a/crates/nebula-tui/src/event_loop/launcher.rs b/crates/nebula-tui/src/event_loop/launcher.rs index 46909b89..28421d6d 100644 --- a/crates/nebula-tui/src/event_loop/launcher.rs +++ b/crates/nebula-tui/src/event_loop/launcher.rs @@ -344,7 +344,7 @@ pub(super) fn empty_band(app: &App) -> Option { let on_card = app .selected_session_row() .is_some_and(|row| row.sref().is_some()); - if !app.launcher_active() || on_card { + if !app.launcher_active() || app.panels || on_card { return None; } let bands = view::bands(app); @@ -2053,7 +2053,8 @@ pub(super) fn toggle_full_screen(app: &mut App, out: &mut Vec) -> app.dirty = true; if app.collapsed { app.collapsed = false; - if app.launcher_pane_hidden || !has_pane(app) { + // The PANELS always have their pane beside the columns. + if !app.panels_active() && (app.launcher_pane_hidden || !has_pane(app)) { super::leave_terminal_lock(app); return "Back to the grid"; } diff --git a/crates/nebula-tui/src/event_loop/panels.rs b/crates/nebula-tui/src/event_loop/panels.rs new file mode 100644 index 00000000..75bba5ee --- /dev/null +++ b/crates/nebula-tui/src/event_loop/panels.rs @@ -0,0 +1,779 @@ +//! The PANELS' keys and clicks (`crate::panels` is the layout's model, +//! `ui::panels_view` its drawing). Most of what the panels answer to is +//! the panel walk `event_loop::handle_key` has always kept under the GRID — +//! `h`/`l` across the columns (`focus_walk`), `j`/`k` down them +//! (`move_selection`), Enter into the pane, and every verb that reads the +//! selection — so this module only takes the keys the GRID owns and gives +//! them their panel meaning, or a word saying they have none here +//! ([`handle_action`]), and translates a click on a row ([`click_row`]) or +//! a notch of the wheel over a column ([`wheel`]). + +use super::{ + activate, attach_selected, is_double_click, jump_attention, launcher, select_project_row, + select_session_row, select_worktree_row, toggle_issues, toggle_open_prs, zoom_pane, +}; +use crate::app::{App, Focus, HitTarget, RowKey}; +use crate::keymap::Action; +use crate::panels::Row; +use nebula_core::ClientRequest; +use std::time::Duration; + +/// What the GRID's PROJECT TAB keys (`x`, the digits, `+`) say beside the +/// columns: the PROJECTS column is where the projects are. +pub(super) const NO_TABS_IN_PANELS: &str = + "no project tabs in the panels — the PROJECTS column lists every project"; +/// What the pane fold (`^``) says: the panels' pane is not one that folds. +const NO_FOLD_IN_PANELS: &str = "the panels' pane doesn't fold — ^F full-screens it"; +/// What `` ` `` says: a checkout's terminals are rows of the SESSIONS +/// column here, not chips over the pane. +const NO_PANE_TABS_IN_PANELS: &str = "terminals are rows under TERMINALS in the SESSIONS column"; +/// What `^F` says with nothing in the pane to full-screen. +const NOTHING_TO_FULL_SCREEN: &str = "no session in the pane — j/k onto one, then ^F"; + +/// A panel key while the PANELS are up — true when it was taken here. Only +/// the keys the GRID's own handler owns (`launcher::handle_action`) and +/// that mean something else beside the columns come through here; every +/// other key falls through to its panel meaning, which reads the same +/// selection the columns' cursors are. +/// +/// * `Space` on a session: the FOLLOW-UP MODAL the grid's card opens +/// (`launcher::follow_up`). Off the SESSIONS column there is no session +/// under the cursor to prompt, and it does nothing. +/// * `^F`: the session under the cursor full-screen ([`toggle_full_screen`]). +/// * `]` / `[`: the attention walk, the meaning they had in the panels — +/// the same ring `.` / `,` walk. +/// * `⇧A`: the SESSIONS column's ARCHIVED group, opened or folded in +/// place ([`toggle_archived`]) — not the grid's swap to a list of every +/// archived session in the project. +/// * The PROJECT TAB keys, the pane fold and the pane's terminal strip +/// have nothing to act on here, and say so. +pub(super) fn handle_action(app: &mut App, action: Action, out: &mut Vec) -> bool { + // Whatever the key does, the focused column reveals its cursor again: + // the walk that follows a wheel brings the cursor back on screen even + // from the column's end, where it has nowhere to move to. + if app.focus != Focus::Terminal { + app.panels_scroll[crate::panels::scroll_slot(app.focus)].reveal_next(); + } + match action { + Action::FollowUp => { + if app.focus == Focus::Sessions { + launcher::follow_up(app); + } + } + Action::ToggleFullScreen => toggle_full_screen(app, out), + Action::NextProjectTab | Action::PrevProjectTab => { + let step = if action == Action::NextProjectTab { + 1 + } else { + -1 + }; + let attaches = crate::config::Config::load().palette_enter_attaches; + jump_attention(app, step, attaches, out); + } + Action::ToggleArchived => toggle_archived(app, out), + Action::CloseProjectTab | Action::SelectProjectTab(_) | Action::ProjectDropdown => { + app.flash = Some(NO_TABS_IN_PANELS.into()); + } + Action::ToggleLauncherPane => app.flash = Some(NO_FOLD_IN_PANELS.into()), + Action::PaneTabs => app.flash = Some(NO_PANE_TABS_IN_PANELS.into()), + _ => return false, + } + true +} + +/// `^F` beside the columns: the session in the pane full-screen with the +/// input lock on — from the SESSIONS column the row under the cursor, +/// attached first, exactly as Enter on it would; from the pane, or the +/// columns above, whatever the pane is showing. `^F` (or `^q`) from the +/// full-screen session comes back down to the pane with the keys still in +/// it (`launcher::toggle_full_screen`, which the locked pane runs). +fn toggle_full_screen(app: &mut App, out: &mut Vec) { + if app.focus == Focus::Sessions { + let Some(row) = app.selected_session_row() else { + app.flash = Some(NOTHING_TO_FULL_SCREEN.into()); + return; + }; + if row.is_archived_agent() { + app.flash = Some(super::AGENT_ARCHIVED.into()); + return; + } + // A link or a Cloud row leads out to the browser and leaves no + // PTY in the pane: there is nothing to full-screen then. + attach_selected(app, out); + if app.focus != Focus::Terminal { + return; + } + } + if app.term.is_none() { + app.flash = Some(NOTHING_TO_FULL_SCREEN.into()); + return; + } + zoom_pane(app, out); + app.dirty = true; +} + +/// `⇧A`, and a click on the `ARCHIVED` header: the SESSIONS column's +/// ARCHIVED group opened under the live rows, or folded back to its one +/// line. The cursor keeps its row; one that was on an archived row the +/// fold took away lands on the last row left, with its session in the +/// pane. +pub(super) fn toggle_archived(app: &mut App, out: &mut Vec) { + app.show_archived = !app.show_archived; + let len = app.visible_session_rows().len(); + if len > 0 && app.sel_session >= len { + select_session_row(app, len - 1, Duration::ZERO, out); + } + app.dirty = true; +} + +/// The cursor onto the PANELS row a click — either button — landed on, its +/// column taking FOCUS: the move the arrow keys make onto it +/// (`select_project_row`, `select_worktree_row`, `select_session_row`), +/// with the context it brings — the project's checkouts, the checkout's +/// session in the pane. False for a header, which has no cursor to move +/// and no menu of its own. +pub(super) fn select_row(app: &mut App, row: Row, out: &mut Vec) -> bool { + match row { + Row::Project(i) => { + if i != app.sel_project { + select_project_row(app, i, out); + } + } + Row::Worktree(i) => { + if i != app.sel_worktree { + select_worktree_row(app, i, out); + } + } + Row::Session(i) => select_session_row(app, i, Duration::ZERO, out), + Row::OpenPrsHeader | Row::IssuesHeader | Row::ArchivedHeader => return false, + } + app.focus = row.focus(); + app.dirty = true; + true +} + +/// A left click on the PANELS: a row takes the cursor ([`select_row`]), +/// and a second click on the same one is Enter on it — a checkout hands +/// FOCUS to its sessions, a session is attached and takes the keys. A +/// group header folds its group, or opens it. +pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { + match row { + Row::OpenPrsHeader => toggle_open_prs(app, out), + Row::IssuesHeader => toggle_issues(app, out), + Row::ArchivedHeader => toggle_archived(app, out), + Row::Project(_) => { + select_row(app, row, out); + } + Row::Worktree(_) => { + select_row(app, row, out); + let Some(id) = app.selected_worktree().map(|w| w.id.clone()) else { + return; + }; + if is_double_click(&mut app.last_session_click, RowKey::Worktree(id)) { + activate::worktrees_row(app, out); + } + } + Row::Session(_) => { + select_row(app, row, out); + let Some(sref) = app.selected_session_row().and_then(|r| r.sref()) else { + return; + }; + if is_double_click(&mut app.last_session_click, RowKey::Session(sref)) { + attach_selected(app, out); + } + } + } +} + +/// Lines a notch of the wheel scrolls a PANELS column: a third of the +/// GRID's card, as `launcher::GRID_WHEEL_ROWS`. +const WHEEL_LINES: isize = 3; + +/// A notch of the wheel over a PANELS column — a row, a group header or +/// the air under them — scrolls that column a few lines under a cursor +/// that stays put (`ColumnScroll::wheel`): the pane keeps reading the +/// session it was on, so a trackpad never swaps it out from under you, +/// and nothing here moves a cursor, FOCUS or sends a request. The scroll +/// is held at the column's ends, and a column that fits moves nothing. +/// The next key that walks the column brings its cursor back on screen +/// (`handle_action`). True when the pointer was over a column; over the +/// pane it is not, and falls through to the pane's own. +pub(super) fn wheel(app: &mut App, over: Option<&HitTarget>, up: bool) -> bool { + let focus = match over { + Some(HitTarget::PanelsRow(row)) => row.focus(), + Some(HitTarget::PanelBg(focus)) => *focus, + _ => return false, + }; + let delta = if up { -WHEEL_LINES } else { WHEEL_LINES }; + if app.panels_scroll[crate::panels::scroll_slot(focus)].wheel(delta) { + app.dirty = true; + } + true +} + +#[cfg(test)] +mod tests { + use super::super::tests::{buffer_text, hse, press, seed_tree, with_config_json}; + use super::super::{apply_config, handle_mouse}; + use crate::app::{App, Focus, HitTarget, Overlay, PromptKind}; + use crate::panels::Row; + use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use nebula_core::{ + Agent, AgentId, AgentKind, AgentStatus, ClientRequest, Entity, Project, ProjectId, + ServerEvent, Worktree, WorktreeId, + }; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + /// `seed_tree`'s `demo` (root `main`, session `agent-1`) with a second + /// checkout `feat` running `polish-nav`, and a second project `web` + /// whose root runs `tidy-css` — the PANELS on. + fn panels_app() -> App { + let mut app = App::new(); + seed_tree(&mut app); + let worktree = |id: &str, project: &str, branch: &str, is_main: bool| { + Entity::Worktree(Worktree { + id: WorktreeId(id.into()), + project_id: ProjectId(project.into()), + path: format!("/tmp/{id}").into(), + branch: branch.into(), + is_main, + sort_order: 1, + }) + }; + let agent = |id: &str, worktree: &str, name: &str, at: i64| { + Entity::Agent(Agent { + id: AgentId(id.into()), + worktree_id: WorktreeId(worktree.into()), + name: name.into(), + status: AgentStatus::Finished, + archived: false, + archived_at: 0, + unseen: false, + kind: AgentKind::Claude, + custom_harness: None, + model: None, + effort: None, + session_id: None, + cloud_session_id: None, + sort_order: 0, + status_changed_at: at, + alive: true, + issue_url: None, + recent_prompts: Vec::new(), + }) + }; + for entity in [ + worktree("w2", "p1", "feat", false), + agent("a2", "w2", "polish-nav", 1), + Entity::Project(Project { + id: ProjectId("p2".into()), + name: "web".into(), + repo_path: "/tmp/web".into(), + sort_order: 1, + }), + worktree("w3", "p2", "main", true), + agent("a3", "w3", "tidy-css", 0), + ] { + hse(&mut app, ServerEvent::EntityUpserted { entity }); + } + app.panels = true; + app + } + + fn draw(app: &mut App) -> (Terminal, String) { + let mut terminal = Terminal::new(TestBackend::new(140, 32)).unwrap(); + terminal.draw(|f| crate::ui::draw(f, app)).unwrap(); + let text = buffer_text(&terminal); + (terminal, text) + } + + fn key(app: &mut App, c: char, out: &mut Vec) { + press(app, KeyCode::Char(c), KeyModifiers::NONE, out); + } + + fn selected_project(app: &App) -> String { + app.selected_project().map(|p| p.name.clone()).unwrap() + } + + /// The PANELS draw the three columns and the pane in place of the + /// PROJECT TABS and the GRID, and nothing of the grid's chrome. + #[test] + fn the_panels_draw_three_columns_and_the_pane() { + let mut app = panels_app(); + assert!(app.panels_active() && !app.launcher_grid()); + let (_, text) = draw(&mut app); + for word in ["PROJECTS", "WORKTREES", "SESSIONS", "TERMINAL"] { + assert!(text.contains(word), "{word}: {text}"); + } + for row in ["demo", "web", "main ⌂ root", "feat", "RECENT"] { + assert!(text.contains(row), "{row}: {text}"); + } + assert!( + !app.hits + .iter() + .any(|(_, h)| matches!(h, HitTarget::LauncherTabAdd | HitTarget::LauncherCard(_))), + "no grid chrome: {text}" + ); + for focus in [Focus::Projects, Focus::Worktrees, Focus::Sessions] { + assert!( + app.hits + .iter() + .any(|(_, h)| *h == HitTarget::PanelBg(focus)), + "{focus:?} has its background" + ); + } + } + + /// The setting is live and only a switch: flipped back and forth + /// mid-session the same project, checkout and session stay selected, + /// and the pane keeps whatever it had. + #[test] + fn switching_the_layout_keeps_the_selection() { + with_config_json(r#"{"layout": "panels"}"#, || { + let mut app = panels_app(); + app.panels = false; + let mut out = Vec::new(); + app.focus = Focus::Projects; + key(&mut app, 'j', &mut out); + let before = (app.sel_project, app.sel_worktree, app.sel_session); + let cfg = crate::config::Config::load(); + apply_config(&mut app, &cfg); + assert!(app.panels_active()); + draw(&mut app); + assert_eq!(before, (app.sel_project, app.sel_worktree, app.sel_session)); + apply_config(&mut app, &crate::config::Config::default()); + assert!(app.launcher_grid()); + let (_, text) = draw(&mut app); + assert_eq!(before, (app.sel_project, app.sel_worktree, app.sel_session)); + assert!(!text.contains("WORKTREES"), "the grid again: {text}"); + }); + } + + /// `h` / `l` walk FOCUS across the columns and stop at the first one; + /// `j` / `k` in PROJECTS move the project, which scopes WORKTREES. + #[test] + fn h_and_l_walk_the_columns_and_j_walks_the_projects() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + key(&mut app, 'h', &mut out); + assert_eq!(app.focus, Focus::Worktrees); + key(&mut app, 'h', &mut out); + assert_eq!(app.focus, Focus::Projects); + key(&mut app, 'h', &mut out); + assert_eq!(app.focus, Focus::Projects, "the walk stops at the first"); + let first = selected_project(&app); + key(&mut app, 'j', &mut out); + let second = selected_project(&app); + assert_ne!(first, second); + let branches: Vec = app + .visible_worktrees() + .iter() + .map(|w| w.branch.clone()) + .collect(); + let want: &[&str] = if second == "web" { + &["main"] + } else { + &["main", "feat"] + }; + assert_eq!(branches, want, "WORKTREES follows the project"); + key(&mut app, 'l', &mut out); + assert_eq!(app.focus, Focus::Worktrees); + } + + /// Tab walks forward and lands in the pane; Enter on a session + /// attaches it and takes the keys. + #[test] + fn tab_and_enter_go_into_the_pane() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Projects; + press(&mut app, KeyCode::Tab, KeyModifiers::NONE, &mut out); + assert_eq!(app.focus, Focus::Worktrees); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert_eq!( + app.focus, + Focus::Sessions, + "Enter on a checkout: its sessions" + ); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert_eq!(app.focus, Focus::Terminal); + assert!(app.term_locked, "Enter on a session takes the keys"); + assert!( + out.iter() + .any(|r| matches!(r, ClientRequest::Attach { .. })), + "{out:?}" + ); + } + + /// The grid's own keys never strand the panels: Space is the + /// FOLLOW-UP MODAL on a session, and the PROJECT TAB keys, the pane + /// fold and its strip only say they have nothing to act on here. + #[test] + fn the_grids_own_keys_are_harmless_beside_the_columns() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + let projects = app.tree.projects.len(); + for c in ['x', '2', '`'] { + key(&mut app, c, &mut out); + assert!(app.overlay.is_none(), "{c}: {:?}", app.overlay); + assert!(app.flash.take().is_some(), "{c} says why not"); + } + assert_eq!(app.tree.projects.len(), projects); + assert!(app.panels_active(), "no tab was closed"); + key(&mut app, ' ', &mut out); + assert!( + matches!( + &app.overlay, + Some(Overlay::Prompt(p)) if matches!(p.kind, PromptKind::FollowUp { .. }) + ), + "{:?}", + app.overlay + ); + } + + /// `^F` on a session row full-screens it with the keys in it; `^F` + /// again comes back down to the panels' pane, keys still there. + #[test] + fn ctrl_f_full_screens_the_session_and_back() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + press( + &mut app, + KeyCode::Char('f'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!(app.collapsed && app.term_locked, "full screen, typing"); + draw(&mut app); + press( + &mut app, + KeyCode::Char('f'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!(!app.collapsed, "back down"); + assert_eq!(app.focus, Focus::Terminal, "into the panels' pane"); + let (_, text) = draw(&mut app); + assert!(text.contains("WORKTREES"), "{text}"); + } + + fn click_at(app: &mut App, target: HitTarget, out: &mut Vec) { + let rect = app + .hits + .iter() + .find(|(_, h)| *h == target) + .map(|(r, _)| *r) + .unwrap_or_else(|| panic!("{target:?} is not on screen")); + handle_mouse( + app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x + 2, + row: rect.y, + modifiers: KeyModifiers::NONE, + }, + out, + ); + } + + /// A click on a row puts the cursor there and its column takes FOCUS; + /// a click on the pane steps into it. + #[test] + fn a_click_selects_a_row_and_the_pane_takes_the_keys() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + draw(&mut app); + let other = if app.sel_project == 0 { 1 } else { 0 }; + click_at( + &mut app, + HitTarget::PanelsRow(Row::Project(other)), + &mut out, + ); + assert_eq!(app.focus, Focus::Projects); + assert_eq!(app.sel_project, other); + draw(&mut app); + click_at(&mut app, HitTarget::PanelsRow(Row::Session(0)), &mut out); + assert_eq!(app.focus, Focus::Sessions); + draw(&mut app); + click_at(&mut app, HitTarget::TerminalPane, &mut out); + assert_eq!(app.focus, Focus::Terminal); + } + + /// `⇧A` opens the ARCHIVED group under the live rows and folds it + /// again, in place: the cursor stays in the checkout it was in. + #[test] + fn shift_a_folds_the_archived_group_in_place() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + let worktree = app.selected_worktree().map(|w| w.id.clone()); + let old = Agent { + archived: true, + id: AgentId("old".into()), + name: "old-run".into(), + worktree_id: worktree.clone().unwrap(), + ..app.tree.agents[0].clone() + }; + hse( + &mut app, + ServerEvent::EntityUpserted { + entity: Entity::Agent(old), + }, + ); + let (_, text) = draw(&mut app); + assert!( + text.contains("▸ ARCHIVED · 1") && !text.contains("old-run"), + "{text}" + ); + press(&mut app, KeyCode::Char('A'), KeyModifiers::SHIFT, &mut out); + let (_, text) = draw(&mut app); + assert!( + text.contains("▾ ARCHIVED · 1") && text.contains("old-run"), + "{text}" + ); + assert_eq!(app.selected_worktree().map(|w| w.id.clone()), worktree); + } + + /// One wheel notch with the pointer on the middle of `target`'s rect. + fn wheel_at( + app: &mut App, + target: HitTarget, + kind: MouseEventKind, + out: &mut Vec, + ) { + let rect = app + .hits + .iter() + .find(|(_, h)| *h == target) + .map(|(r, _)| *r) + .unwrap_or_else(|| panic!("{target:?} is not on screen")); + handle_mouse( + app, + MouseEvent { + kind, + column: rect.x + 2, + row: rect.y, + modifiers: KeyModifiers::NONE, + }, + out, + ); + } + + /// `demo`'s root with `n` more sessions, so SESSIONS outgrows its + /// column; the cursor on the first row. + fn long_sessions(app: &mut App, n: usize) { + let root = app.selected_worktree().map(|w| w.id.clone()).unwrap(); + for i in 0..n { + let more = Agent { + id: AgentId(format!("long{i}")), + name: format!("long-{i}"), + worktree_id: root.clone(), + ..app.tree.agents[0].clone() + }; + hse( + app, + ServerEvent::EntityUpserted { + entity: Entity::Agent(more), + }, + ); + } + app.sel_project = 0; + app.sel_worktree = 0; + app.sel_session = 0; + } + + fn on_screen(app: &App, row: Row) -> bool { + app.hits + .iter() + .any(|(_, h)| *h == HitTarget::PanelsRow(row)) + } + + /// A notch over a column taller than its height scrolls it under the + /// cursor: no cursor, FOCUS or request moves. + #[test] + fn the_wheel_scrolls_a_long_column_under_its_cursor() { + let mut app = panels_app(); + let mut out = Vec::new(); + long_sessions(&mut app, 40); + app.focus = Focus::Terminal; + draw(&mut app); + assert!(on_screen(&app, Row::Session(0))); + let cursors = (app.sel_project, app.sel_worktree, app.sel_session); + wheel_at( + &mut app, + HitTarget::PanelsRow(Row::Session(0)), + MouseEventKind::ScrollDown, + &mut out, + ); + assert_eq!(app.panels_scroll[2].top, 3, "three lines a notch"); + assert_eq!( + cursors, + (app.sel_project, app.sel_worktree, app.sel_session) + ); + assert_eq!(app.focus, Focus::Terminal, "FOCUS stays"); + assert!(out.is_empty(), "no attach: {out:?}"); + draw(&mut app); + assert!(!on_screen(&app, Row::Session(0)), "the window moved"); + assert!(on_screen(&app, Row::Session(5))); + wheel_at( + &mut app, + HitTarget::PanelBg(Focus::Sessions), + MouseEventKind::ScrollUp, + &mut out, + ); + assert_eq!(app.panels_scroll[2].top, 0, "a notch up scrolls back"); + assert_eq!(app.panels_scroll[0].top + app.panels_scroll[1].top, 0); + } + + /// A column that fits its height moves nothing. + #[test] + fn the_wheel_moves_nothing_in_a_column_that_fits() { + let mut app = panels_app(); + let mut out = Vec::new(); + draw(&mut app); + let before = app.panels_scroll; + for focus in [Focus::Projects, Focus::Worktrees, Focus::Sessions] { + wheel_at( + &mut app, + HitTarget::PanelBg(focus), + MouseEventKind::ScrollDown, + &mut out, + ); + } + assert_eq!(before, app.panels_scroll); + assert!(out.is_empty()); + } + + /// The scroll holds at the column's first line and at the one that + /// puts the last line on the bottom row. + #[test] + fn the_wheel_holds_at_both_ends() { + let mut app = panels_app(); + let mut out = Vec::new(); + long_sessions(&mut app, 40); + draw(&mut app); + let at = HitTarget::PanelBg(Focus::Sessions); + wheel_at(&mut app, at.clone(), MouseEventKind::ScrollUp, &mut out); + assert_eq!(app.panels_scroll[2].top, 0, "held at the top"); + for _ in 0..100 { + wheel_at(&mut app, at.clone(), MouseEventKind::ScrollDown, &mut out); + } + let max = app.panels_scroll[2].max; + assert!(max > 0, "the column is longer than it is tall"); + assert_eq!(app.panels_scroll[2].top, max, "held at the bottom"); + draw(&mut app); + assert!(on_screen(&app, Row::Session(40)), "the last row shows"); + } + + /// With the cursor's row wheeled off screen, the next `j` or `k` in the + /// column brings it back — even a `k` on the first row, which has + /// nowhere to go. + #[test] + fn a_walk_after_the_wheel_brings_the_cursor_back() { + let mut app = panels_app(); + let mut out = Vec::new(); + long_sessions(&mut app, 40); + app.focus = Focus::Sessions; + draw(&mut app); + let at = HitTarget::PanelBg(Focus::Sessions); + for _ in 0..4 { + wheel_at(&mut app, at.clone(), MouseEventKind::ScrollDown, &mut out); + } + draw(&mut app); + assert!(!on_screen(&app, Row::Session(0)), "wheeled away"); + key(&mut app, 'k', &mut out); + assert_eq!(app.sel_session, 0); + draw(&mut app); + assert!(on_screen(&app, Row::Session(0)), "k at the top: back"); + for _ in 0..100 { + wheel_at(&mut app, at.clone(), MouseEventKind::ScrollDown, &mut out); + } + draw(&mut app); + assert!(!on_screen(&app, Row::Session(0))); + key(&mut app, 'j', &mut out); + assert_eq!(app.sel_session, 1); + draw(&mut app); + assert!(on_screen(&app, Row::Session(1)), "j: back on the cursor"); + } + + /// Over the pane the wheel is the pane's: no column's cursor moves and + /// FOCUS stays where it was. + #[test] + fn the_wheel_over_the_pane_leaves_the_columns_alone() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Terminal; + let before = (app.sel_project, app.sel_worktree, app.sel_session); + draw(&mut app); + wheel_at( + &mut app, + HitTarget::TerminalPane, + MouseEventKind::ScrollDown, + &mut out, + ); + assert_eq!(before, (app.sel_project, app.sel_worktree, app.sel_session)); + assert_eq!(app.focus, Focus::Terminal); + } + + /// `?` beside the columns teaches the columns' keys, not the grid's + /// cards and project tabs. + #[test] + fn question_mark_in_the_panels_describes_the_panels() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + draw(&mut app); + key(&mut app, '?', &mut out); + assert!( + matches!(app.overlay, Some(Overlay::Help(_))), + "{:?}", + app.overlay + ); + let (_, text) = draw(&mut app); + for want in [ + "THE COLUMNS", + "walk the three columns", + "session: follow-up modal", + "fold the ARCHIVED group", + "project tabs: none here", + ] { + assert!(text.contains(want), "{want}: {text}"); + } + for gone in [ + "NAVIGATE & SEARCH", + "walk the cards", + "fold / unfold the pane", + ] { + assert!(!text.contains(gone), "{gone}: {text}"); + } + } + + /// With the grid up `?` is what it always was. + #[test] + fn question_mark_in_the_grid_is_unchanged() { + let mut app = panels_app(); + app.panels = false; + let mut out = Vec::new(); + draw(&mut app); + key(&mut app, '?', &mut out); + assert!( + matches!(app.overlay, Some(Overlay::Help(_))), + "{:?}", + app.overlay + ); + let (_, text) = draw(&mut app); + for want in [ + "NAVIGATE & SEARCH", + "walk the cards", + "fold / unfold the pane", + ] { + assert!(text.contains(want), "{want}: {text}"); + } + assert!(!text.contains("THE COLUMNS"), "{text}"); + } +} diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index f71b9edd..2d0916e3 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -27,6 +27,7 @@ pub(crate) mod list_hit; pub mod markdown; pub mod overlay_close; pub mod palette; +pub mod panels; pub mod perf; pub mod pr_cache; pub mod pr_modal; diff --git a/crates/nebula-tui/src/panels.rs b/crates/nebula-tui/src/panels.rs new file mode 100644 index 00000000..1986f00d --- /dev/null +++ b/crates/nebula-tui/src/panels.rs @@ -0,0 +1,446 @@ +//! The PANELS: the three-column layout nebula drew before the GRID, back +//! as Settings → Appearance → **Layout** `panels`. PROJECTS | WORKTREES | +//! SESSIONS down the left, each a list of rows with a STATUS DOT and the +//! cursor's selection bar, and the PANE beside them reading the session +//! under the SESSIONS cursor. Picking a project scopes the worktrees, +//! picking a worktree scopes the sessions; `h`/`l` walk FOCUS across the +//! columns, `j`/`k` the rows, Enter goes into the pane. +//! +//! Nothing here is a second copy of the tree or of the selection: every +//! row is a row of the lists the GRID's cursor already indexes — +//! `App::project_rows`, `App::worktree_rows`, `App::visible_session_rows` +//! — so `sel_project`, `sel_worktree` and `sel_session` are the cursors of +//! both layouts, and switching between them lands on the same rows with +//! the same session in the pane. The PROJECTS column stands in for the +//! PROJECT TABS: it lists every project on the machine, and the lit one +//! is whichever its cursor is on, which is also the tab the GRID lights. +//! +//! What lives here is what the layout adds: the columns' widths +//! ([`columns`]), the lines each column lays out ([`project_lines`], +//! [`worktree_lines`], [`session_lines`]) and the scroll that keeps the +//! cursor's line on screen ([`scroll_to`]) until the wheel moves it +//! ([`ColumnScroll`]). The keys are +//! `event_loop::panels`'s and the drawing `ui::panels_view`'s. + +use crate::app::{App, Focus, SessionRow, WorktreeRow}; +use nebula_core::{ProjectId, WorktreeId}; +use ratatui::layout::{Constraint, Layout, Rect}; + +/// Widths of the PROJECTS, WORKTREES and SESSIONS columns on a body wide +/// enough for them and the pane: the widths the three panels opened at +/// before they were dragged. +pub const WIDTHS: [u16; 3] = [20, 22, 32]; +/// Narrowest a column is squeezed to on a narrow body. +pub const MIN_W: u16 = 10; +/// Columns the PANE always keeps, whatever the columns would like. +pub const MIN_PANE_W: u16 = 20; + +/// Where a row — or a header that folds — sits in the PANELS, as a click +/// target (`HitTarget::PanelsRow`). The indices are the cursors': a +/// project's place in `App::project_rows`, a worktree row's in +/// `App::worktree_rows`, a session row's in `App::visible_session_rows`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Row { + Project(usize), + Worktree(usize), + Session(usize), + /// The WORKTREES column's `OPEN PRS` header: a click folds the group. + OpenPrsHeader, + /// Its `ISSUES` header, the same way. + IssuesHeader, + /// The SESSIONS column's `ARCHIVED` header: a click opens the group + /// or folds it, as `⇧A` does. + ArchivedHeader, +} + +impl Row { + /// The column the row is in — the FOCUS a click on it takes. + pub fn focus(self) -> Focus { + match self { + Row::Project(_) => Focus::Projects, + Row::Worktree(_) | Row::OpenPrsHeader | Row::IssuesHeader => Focus::Worktrees, + Row::Session(_) | Row::ArchivedHeader => Focus::Sessions, + } + } +} + +/// One line a column lays out, top to bottom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Line { + /// Air between two groups. + Blank, + /// A group's name over its rows — `TERMINALS`, `▾ OPEN PRS · 3`. + /// `fold` is the header's own click target, for a group that folds. + Header { text: String, fold: Option }, + /// One row, by its place in the column's list. + Row(Row), +} + +/// The four rects of the PANELS: the three columns at [`WIDTHS`], and the +/// PANE taking every column left over. A body too narrow for that squeezes +/// the columns in proportion — down to [`MIN_W`] each — so the pane keeps +/// [`MIN_PANE_W`] for as long as the window allows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Columns { + pub projects: Rect, + pub worktrees: Rect, + pub sessions: Rect, + pub pane: Rect, +} + +impl Columns { + /// The column FOCUS `focus` names, or the pane. + pub fn of(&self, focus: Focus) -> Rect { + match focus { + Focus::Projects => self.projects, + Focus::Worktrees => self.worktrees, + Focus::Sessions => self.sessions, + Focus::Terminal => self.pane, + } + } +} + +/// Lay the PANELS out over `body` ([`Columns`]). +pub fn columns(body: Rect) -> Columns { + let want: u16 = WIDTHS.iter().sum(); + let budget = body.width.saturating_sub(MIN_PANE_W); + let widths = WIDTHS.map(|w| { + if budget >= want { + w + } else { + (u32::from(w) * u32::from(budget) / u32::from(want)) as u16 + } + .max(MIN_W) + }); + let [projects, worktrees, sessions, pane] = Layout::horizontal([ + Constraint::Length(widths[0]), + Constraint::Length(widths[1]), + Constraint::Length(widths[2]), + Constraint::Min(0), + ]) + .areas(body); + Columns { + projects, + worktrees, + sessions, + pane, + } +} + +/// The PROJECTS column: one row per project, most recently worked in +/// first — `App::project_rows`' own order, which is what `sel_project` +/// indexes. +pub fn project_lines(app: &App) -> Vec { + (0..app.project_rows().len()) + .map(|i| Line::Row(Row::Project(i))) + .collect() +} + +/// The WORKTREES column: the project's checkouts — the ROOT WORKTREE +/// first, with a line of air under it — then the `OPEN PRS` group (its +/// pull requests, each with the checkout on its head branch nested under +/// it) and the `ISSUES` group, each under a header that folds it. A +/// folded group is its header alone, still counting what it holds. +pub fn worktree_lines(app: &App) -> Vec { + let rows = app.worktree_rows(); + let mut lines = Vec::new(); + let mut prs_headed = false; + let mut issues_headed = false; + let gap = |lines: &mut Vec| { + if !lines.is_empty() { + lines.push(Line::Blank); + } + }; + for (i, row) in rows.iter().enumerate() { + match row { + WorktreeRow::Checkout(w) => { + lines.push(Line::Row(Row::Worktree(i))); + let more = rows + .get(i + 1) + .is_some_and(|r| matches!(r, WorktreeRow::Checkout(_))); + if w.is_main && more { + lines.push(Line::Blank); + } + } + WorktreeRow::Pr(_) | WorktreeRow::PrCheckout(_) => { + if !prs_headed { + prs_headed = true; + gap(&mut lines); + lines.push(open_prs_header(app)); + } + lines.push(Line::Row(Row::Worktree(i))); + } + WorktreeRow::Issue(_) => { + if !prs_headed && app.open_prs_collapsed && !app.listed_open_prs().is_empty() { + prs_headed = true; + gap(&mut lines); + lines.push(open_prs_header(app)); + } + if !issues_headed { + issues_headed = true; + gap(&mut lines); + lines.push(issues_header(app)); + } + lines.push(Line::Row(Row::Worktree(i))); + } + } + } + // A folded group has no rows to have put its header up on the way. + if !prs_headed && !app.listed_open_prs().is_empty() { + gap(&mut lines); + lines.push(open_prs_header(app)); + } + if !issues_headed && !app.listed_issues().is_empty() { + gap(&mut lines); + lines.push(issues_header(app)); + } + lines +} + +/// `▾ OPEN PRS · 3` over the rows, `▸` with them folded away. +fn open_prs_header(app: &App) -> Line { + Line::Header { + text: format!( + "{} OPEN PRS · {}", + fold_glyph(app.open_prs_collapsed), + app.listed_open_prs().len() + ), + fold: Some(Row::OpenPrsHeader), + } +} + +/// `▾ ISSUES · 2`, the same way. +fn issues_header(app: &App) -> Line { + Line::Header { + text: format!( + "{} ISSUES · {}", + fold_glyph(app.issues_collapsed), + app.listed_issues().len() + ), + fold: Some(Row::IssuesHeader), + } +} + +/// The disclosure triangle: what a click on the header would do. +fn fold_glyph(folded: bool) -> &'static str { + if folded { + "▸" + } else { + "▾" + } +} + +/// The SESSIONS column: the checkout's live sessions under `RECENT`, most +/// recently touched first, then its `TERMINALS`, its `PULL REQUESTS` and +/// last the `ARCHIVED` group — open (`⇧A`, or a click on its header) or +/// folded to the one line that counts it. The order is the one +/// `App::visible_session_rows` lists, which is what `sel_session` indexes. +pub fn session_lines(app: &App) -> Vec { + let rows = app.visible_session_rows(); + // The checkout's live and archived sessions, archived counted whether + // the group is open or not: a folded group still says what it holds. + let checkout = app.selected_worktree().map(|w| w.id.clone()); + let (live, archived) = app + .tree + .agents + .iter() + .filter(|a| Some(&a.worktree_id) == checkout.as_ref()) + .fold((0, 0), |(live, gone), a| { + if a.archived { + (live, gone + 1) + } else { + (live + 1, gone) + } + }); + let mut lines = Vec::new(); + let group = |lines: &mut Vec, title: &str, fold: Option, rows: &[usize]| { + if !lines.is_empty() { + lines.push(Line::Blank); + } + lines.push(Line::Header { + text: title.to_string(), + fold, + }); + lines.extend(rows.iter().map(|i| Line::Row(Row::Session(*i)))); + }; + let of = |want: fn(&SessionRow) -> bool| -> Vec { + rows.iter() + .enumerate() + .filter(|(_, row)| want(row)) + .map(|(i, _)| i) + .collect() + }; + let recent: Vec = (0..live.min(rows.len())).collect(); + let terminals = of(|row| matches!(row, SessionRow::Terminal(_))); + let links = of(|row| matches!(row, SessionRow::Link(_))); + let gone = of(SessionRow::is_archived_agent); + if !recent.is_empty() { + group(&mut lines, "RECENT", None, &recent); + } + if !terminals.is_empty() { + group(&mut lines, "TERMINALS", None, &terminals); + } + if !links.is_empty() { + group(&mut lines, "PULL REQUESTS", None, &links); + } + if archived > 0 { + let title = if app.show_archived { + format!("▾ ARCHIVED · {archived}") + } else { + format!("▸ ARCHIVED · {archived}") + }; + group(&mut lines, &title, Some(Row::ArchivedHeader), &gone); + } + lines +} + +/// The first line of `lines` to draw in a column `height` rows tall, so +/// the line holding `cursor` is on screen: the stateless follow-window +/// every overlay list scrolls by (`app::window_start`), sliding only as +/// far as the cursor's line needs. The group header over a row is the +/// line above it, so it is on screen with the row whenever there is room. +pub fn scroll_to(lines: &[Line], cursor: Option, height: usize) -> usize { + cursor + .and_then(|c| lines.iter().position(|l| *l == Line::Row(c))) + .map_or(0, |at| crate::app::window_start(at, height.max(1))) +} + +/// Where the column FOCUS names keeps its scroll in `App::panels_scroll`. +/// The pane has none and is never asked: it reads as the last column. +pub fn scroll_slot(focus: Focus) -> usize { + match focus { + Focus::Projects => 0, + Focus::Worktrees => 1, + Focus::Sessions | Focus::Terminal => 2, + } +} + +/// One column's scroll: the first line drawn, which the wheel moves under +/// a cursor that stays put and the cursor's own moves bring back on +/// screen. The columns lay out and clamp it each frame ([`settle`]); the +/// wheel (`event_loop::panels::wheel`) reads what that left in `max`. +/// +/// [`settle`]: ColumnScroll::settle +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ColumnScroll { + /// The first line drawn. + pub top: usize, + /// Furthest `top` goes: the last line on the bottom row, zero for a + /// column that fits. + pub max: usize, + /// The cursor and line count the last frame drew; a frame that finds + /// either changed reveals the cursor again. `None` is "reveal it". + seen: Option<(Option, usize)>, +} + +impl ColumnScroll { + /// The first line to draw for `lines` in a column `height` rows tall: + /// `top` held within the column, and slid only as far as the cursor's + /// line needs ([`scroll_to`]) when the cursor — or what the column + /// lists — is not what the last frame drew, so a wheeled-away cursor + /// stays away until it moves. + pub fn settle(&mut self, lines: &[Line], cursor: Option, height: usize) -> usize { + let height = height.max(1); + self.max = lines.len().saturating_sub(height); + if self.seen != Some((cursor, lines.len())) { + self.seen = Some((cursor, lines.len())); + let at = cursor.and_then(|c| lines.iter().position(|l| *l == Line::Row(c))); + match at { + Some(at) if at < self.top => self.top = at, + Some(at) if at >= self.top + height => self.top = scroll_to(lines, cursor, height), + _ => {} + } + } + self.top = self.top.min(self.max); + self.top + } + + /// A notch of the wheel: `delta` lines, held at the column's ends. False + /// when the column fits, or is already at that end, and nothing moved. + pub fn wheel(&mut self, delta: isize) -> bool { + let next = self.top.saturating_add_signed(delta).min(self.max); + std::mem::replace(&mut self.top, next) != next + } + + /// The next frame reveals the cursor, whether or not it moved: what a + /// key that walks the column asks for, so one pressed at the column's + /// end still brings a wheeled-away cursor back. + pub fn reveal_next(&mut self) { + self.seen = None; + } +} + +/// The newest finish under `worktree` is still a ONE-SHOT SWEEP's worth +/// old (`app::fresh_done`): its row sweeps blue once, as the session's. +pub fn worktree_fresh_done(app: &App, worktree: &WorktreeId) -> bool { + let now = crate::app::now_ms(); + app.tree + .agents + .iter() + .any(|a| &a.worktree_id == worktree && crate::app::fresh_done(a, now)) +} + +/// The same over every checkout of `project`. +pub fn project_fresh_done(app: &App, project: &ProjectId) -> bool { + app.tree + .worktrees + .iter() + .filter(|w| &w.project_id == project) + .any(|w| worktree_fresh_done(app, &w.id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(width: u16) -> Rect { + Rect::new(0, 0, width, 30) + } + + /// A body with room for all three at their widths and the pane gets + /// every column left over. + #[test] + fn the_columns_open_at_their_widths_and_the_pane_takes_the_rest() { + let c = columns(body(190)); + assert_eq!( + [c.projects.width, c.worktrees.width, c.sessions.width], + WIDTHS + ); + assert_eq!(c.pane.x, 74); + assert_eq!(c.pane.width, 190 - 74); + assert_eq!(c.of(Focus::Worktrees), c.worktrees); + assert_eq!(c.of(Focus::Terminal), c.pane); + } + + /// A narrow body squeezes the columns, never under their floor, so the + /// pane keeps its own. + #[test] + fn a_narrow_body_squeezes_the_columns_for_the_pane() { + let c = columns(body(80)); + assert!(c.pane.width >= MIN_PANE_W, "{c:?}"); + for w in [c.projects.width, c.worktrees.width, c.sessions.width] { + assert!((MIN_W..32).contains(&w), "{c:?}"); + } + } + + fn rows(n: usize) -> Vec { + (0..n).map(|i| Line::Row(Row::Session(i))).collect() + } + + /// The window follows the cursor down and is back at the top for a + /// cursor that fits there. + #[test] + fn the_scroll_keeps_the_cursor_on_screen() { + let lines = rows(20); + assert_eq!(scroll_to(&lines, Some(Row::Session(3)), 5), 0); + assert_eq!(scroll_to(&lines, Some(Row::Session(12)), 5), 8); + assert_eq!(scroll_to(&lines, None, 5), 0, "no cursor, no scroll"); + } + + #[test] + fn a_row_knows_its_column() { + assert_eq!(Row::Project(2).focus(), Focus::Projects); + assert_eq!(Row::IssuesHeader.focus(), Focus::Worktrees); + assert_eq!(Row::ArchivedHeader.focus(), Focus::Sessions); + } +} diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index f49bff24..10e216ee 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -14,6 +14,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use ratatui::Frame; mod launcher_view; +mod panels_view; /// Outer size of the editor modal, as (width, height) percent of the frame. /// Shared with the event loop's pre-draw PTY size guess. @@ -181,6 +182,12 @@ const CONFIRM_MIN_W: u16 = 52; const HELP_W: u16 = 92; /// The help overlay's key column: chords past it are dropped whole. const HELP_KEY_W: usize = 14; +/// What a help entry shows in the key column, and one titled group of them. +enum HelpKeys { + Lit(&'static str), + Act(&'static [crate::keymap::Action]), +} +type HelpSection = (&'static str, &'static [(HelpKeys, &'static str)]); const SETTINGS_W: u16 = 84; const MEMORY_W: u16 = 74; const HOSTS_W: u16 = 64; @@ -239,6 +246,17 @@ fn draw_screen(f: &mut Frame, app: &mut App) { // // Any project on the machine puts it up; with none, the splash below // is the first run's "open a project". + // + // Settings → Appearance → **Layout** `panels` draws the PANELS in its + // place: PROJECTS | WORKTREES | SESSIONS beside the pane, on the same + // selection and the same attached session. + if app.panels_active() { + panels_view::draw(f, app, body); + draw_footer(f, app, footer); + draw_overlay(f, app); + draw_vim(f, app); + return; + } if app.launcher_active() { // `launcher_view::draw` takes `body_area` for the grid's half, so // the whole body is kept here for the pane drag to measure against. @@ -949,12 +967,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // are for keys that belong to an overlay rather than the // grid, which is why they aren't rebindable. use crate::keymap::Action::*; - enum HelpKeys { - Lit(&'static str), - Act(&'static [crate::keymap::Action]), - } use HelpKeys::{Act, Lit}; - type HelpSection = (&'static str, &'static [(HelpKeys, &'static str)]); const LEFT: &[HelpSection] = &[ ( "NAVIGATE & SEARCH", @@ -1063,6 +1076,13 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { ], ), ]; + // The PANELS have their own keys to teach: the grid's cards, + // project tabs and pane fold are not drawn there. + let (left, right) = if app.panels_active() { + panels_view::help_sections() + } else { + (LEFT, RIGHT) + }; // What to print in the key column: a literal, or every chord // each action currently answers to but the ⌘ aliases // (`Keymap::shown_chords`). @@ -1105,7 +1125,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { .sum::() + sections.len().saturating_sub(1) as u16 }; - let height = rows(LEFT).max(rows(RIGHT)) + 2; + let height = rows(left).max(rows(right)) + 2; let area = centered_rect(f.area(), HELP_W, height); f.render_widget(Clear, area); let block = Block::default() @@ -1148,8 +1168,8 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } lines }; - f.render_widget(Paragraph::new(column(LEFT, left_a.width)), left_a); - f.render_widget(Paragraph::new(column(RIGHT, right_a.width)), right_a); + f.render_widget(Paragraph::new(column(left, left_a.width)), left_a); + f.render_widget(Paragraph::new(column(right, right_a.width)), right_a); // Record the drawn area for click hit-testing. if let Some(Overlay::Help(h)) = &mut app.overlay { h.area = area; @@ -3519,6 +3539,9 @@ fn pty_cursor_cell(screen: &vt100::Screen, area: Rect) -> Option { fn draw_terminal(f: &mut Frame, app: &mut App, area: Rect) { let th = app.theme; let focused = app.focus == Focus::Terminal; + // The LAUNCHER VIEW's pane chrome; the PANELS' pane is the plain + // `TERMINAL · name` frame it always was. + let grid = app.launcher_active() && !app.panels_active(); // A cursor is resting on an open pull request — the Worktrees cursor // on a PROJECT OPEN PRS GROUP row, or the focused Sessions cursor on // the PR ROW: the pane reads it. The attachment underneath stays live — @@ -3570,7 +3593,7 @@ fn draw_terminal(f: &mut Frame, app: &mut App, area: Rect) { // The LAUNCHER VIEW's pane says nothing of the lock: its header's // right end is the CLOSE BUTTON, and the accent rule under the // strip already says the keys are in there. - Some(_) if app.term_locked && !app.launcher_active() => Some(Span::styled( + Some(_) if app.term_locked && !grid => Some(Span::styled( "INPUT".to_string(), Style::default().fg(th.accent).add_modifier(Modifier::BOLD), )), @@ -3584,9 +3607,9 @@ fn draw_terminal(f: &mut Frame, app: &mut App, area: Rect) { // cursor itself, so the panels' ` · ` is not added beside // it: with the pane on a terminal the attachment IS that terminal, // and the SESSION tab has to go on saying what it would come back to. - let inner = if app.launcher_active() && app.collapsed { + let inner = if grid && app.collapsed { launcher_view::crumb_frame(f, app, area) - } else if app.launcher_active() { + } else if grid { launcher_view::pane_frame(f, app, area, right, focused) } else { terminal_frame(f, area, left, right, focused, th) @@ -3664,7 +3687,7 @@ fn draw_terminal(f: &mut Frame, app: &mut App, area: Rect) { // The LAUNCHER VIEW's pane with nothing in it — a project with no // session yet — is an empty panel: the strip over it already says // which key opens a terminal here, and is a button for it. - None if app.launcher_active() => (Vec::new(), Vec::new()), + None if grid => (Vec::new(), Vec::new()), None => { // Empty-pane hero: vertically centered wordmark + a compact // key cheat-sheet, so the big blank pane earns its keep. diff --git a/crates/nebula-tui/src/ui/panels_view.rs b/crates/nebula-tui/src/ui/panels_view.rs new file mode 100644 index 00000000..ecdfac8d --- /dev/null +++ b/crates/nebula-tui/src/ui/panels_view.rs @@ -0,0 +1,610 @@ +//! The PANELS' drawing (`crate::panels` is their model, +//! `event_loop::panels` their keys): PROJECTS | WORKTREES | SESSIONS down +//! the left of the body, each column a header over a list of rows — a +//! STATUS DOT, the name, how long since it moved and what it runs on — +//! with the cursor's row on the raised selection bar, and the PANE beside +//! them reading the session under the SESSIONS cursor (`draw_terminal`, +//! the very pane the GRID's full-screen session is drawn into). The +//! focused column wears the FOCUS TINT, as the pane does when it has the +//! keys. +//! +//! Every row registers a `HitTarget::PanelsRow` ahead of its column's +//! `PanelBg`, so a click lands on the row and a click on the air under the +//! rows only takes FOCUS. + +use super::{ + ago_badge, draw_focus_tint, draw_terminal, fit_ago, key_hint, render_button, row_rect, + status_color, status_dot, status_name_spans, sweep_ramp, truncate, HelpSection, + PENDING_SESSION_BADGE, +}; +use crate::app::{App, Focus, HitTarget, SessionRow, WorktreeRow}; +use crate::keymap::Action; +use crate::panels::{Line as PanelLine, Row}; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +/// Left gutter every row gets from its one-column selection marker plus +/// its two-column STATUS DOT: a column's title takes the same indent, so +/// it lines up with the names under it. +const ROW_GUTTER: &str = " "; +/// What a checkout's row says while git is still cutting it. +const PENDING_WORKTREE_BADGE: &str = " creating"; +/// The ROOT WORKTREE's badge, and the glyph alone on a narrow column. +const ROOT_BADGE: &str = " ⌂ root"; +const ROOT_GLYPH: &str = " ⌂"; +/// The RUNNING badge of a checkout whose RUN COMMAND is up (`r`). +const RUN_BADGE: &str = " ▶"; +/// What hangs a checkout under the pull request on its head branch. +const NESTED_INDENT: &str = "└"; + +/// The PANELS over `body`: the three columns and the pane beside them. +pub(super) fn draw(f: &mut Frame, app: &mut App, body: Rect) { + app.body_area = body; + let cols = crate::panels::columns(body); + draw_projects(f, app, cols.projects); + draw_worktrees(f, app, cols.worktrees); + draw_sessions(f, app, cols.sessions); + draw_terminal(f, app, cols.pane); + // The FOCUS TINT on the column with the keys — inside its rule, so the + // rule stays the boundary between two columns rather than part of one. + let tinted = match app.focus { + Focus::Terminal => cols.pane, + column => { + let area = cols.of(column); + Rect { + width: area.width.saturating_sub(1), + ..area + } + } + }; + draw_focus_tint(f.buffer_mut(), tinted, app.theme); +} + +/// A column's frame: its rule down the right, a blank row, the title with +/// its count, a blank row. Returns the rect its list fills, one column +/// short of the rule so a row's text never touches it. +fn column(f: &mut Frame, area: Rect, title: &str, count: usize, focused: bool, th: Theme) -> Rect { + let block = Block::default() + .borders(Borders::RIGHT) + .border_style(Style::default().fg(th.edge)); + let inner = block.inner(area); + f.render_widget(block, area); + let title_style = if focused { + Style::default().fg(th.accent).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(th.muted).add_modifier(Modifier::BOLD) + }; + if let Some(r) = row_rect(inner, 1) { + let mut spans = vec![Span::styled(format!("{ROW_GUTTER}{title}"), title_style)]; + if count > 0 { + spans.push(Span::styled( + format!(" · {count}"), + Style::default().fg(th.dim), + )); + } + f.render_widget(Paragraph::new(Line::from(spans)), r); + } + Rect { + y: inner.y + 3, + height: inner.height.saturating_sub(3), + width: inner.width.saturating_sub(1), + ..inner + } +} + +/// A column's list: the `lines` from where the column is scrolled to +/// (`panels::ColumnScroll`, which brings the `cursor`'s row back on screen +/// when it moves), each row drawn by `row` as its spans +/// and the color its selection rail takes. Registers each row, and each +/// header that folds, as a `PanelsRow`, then the whole column as its +/// `PanelBg`. +#[allow(clippy::too_many_arguments)] +fn draw_list( + f: &mut Frame, + app: &mut App, + area: Rect, + list: Rect, + lines: &[PanelLine], + cursor: Option, + focus: Focus, + mut row: impl FnMut(&App, Row, usize) -> (Vec>, Color), +) { + let th = app.theme; + let focused = app.focus == focus; + let height = usize::from(list.height); + let top = app.panels_scroll[crate::panels::scroll_slot(focus)].settle(lines, cursor, height); + for (y, line) in lines.iter().skip(top).take(height).enumerate() { + let Some(r) = row_rect(list, y) else { + break; + }; + match line { + PanelLine::Blank => {} + PanelLine::Header { text, fold } => { + f.render_widget( + Paragraph::new(Span::styled( + format!(" {text}"), + Style::default().fg(th.dim), + )), + r, + ); + if let Some(fold) = fold { + app.hits.push((r, HitTarget::PanelsRow(*fold))); + } + } + PanelLine::Row(at) => { + let (spans, mark) = row(app, *at, usize::from(r.width)); + let selected = Some(*at) == cursor; + render_button(f, r, vec![spans], selected, focused, th, 0, mark); + app.hits.push((r, HitTarget::PanelsRow(*at))); + } + } + } + app.hits.push((area, HitTarget::PanelBg(focus))); +} + +/// The `?` overlay's two columns while the PANELS are up: the keys the +/// columns answer to, in place of the grid's cards and PROJECT TABS. Every +/// chord is the live keymap's, as the grid's help is; the rows that read +/// the selection — checkouts, GitHub, sessions — are the grid's own. +pub(super) fn help_sections() -> (&'static [HelpSection], &'static [HelpSection]) { + use super::HelpKeys::{Act, Lit}; + use Action::*; + const LEFT: &[HelpSection] = &[ + ( + "THE COLUMNS", + &[ + (Act(&[FocusLeft, FocusRight]), "walk the three columns"), + (Act(&[FocusNext]), "next column, then the pane"), + (Act(&[MoveDown, MoveUp]), "walk the column's rows"), + (Act(&[HalfPageDown, HalfPageUp]), "half a page of rows"), + (Act(&[Activate]), "drill in; session: attach"), + (Act(&[FollowUp]), "session: follow-up modal"), + (Act(&[ToggleFullScreen]), "session full-screen / back"), + (Act(&[ToggleArchived]), "fold the ARCHIVED group"), + (Act(&[Palette]), "fuzzy jump to anything"), + ( + Act(&[NextAttention, PrevAttention, NextProjectTab, PrevProjectTab]), + "next/prev session needing you", + ), + ( + Act(&[CloseProjectTab, ProjectDropdown]), + "project tabs: none here", + ), + (Lit("click"), "select; again: Enter"), + (Lit("wheel"), "scroll the column under it"), + ], + ), + ( + "CHECKOUTS & GITHUB", + &[ + (Act(&[OpenWorktree]), "open in editor (open command)"), + (Act(&[GitDiff]), "diff (^r reviewed, ^t tree)"), + (Act(&[OpenRepo]), "the repo on GitHub"), + (Act(&[OpenPullRequest, OpenIssue]), "PR / issue on GitHub"), + (Act(&[RefreshPullRequests]), "reload PRs + issues (GitHub)"), + (Act(&[Issues]), "issues: prompt, preset, edit"), + (Act(&[PullRequests]), "pull requests: read / launch"), + (Act(&[SwitchBranch]), "switch the ⌂ root's branch"), + ], + ), + ]; + const RIGHT: &[HelpSection] = &[ + ( + "SESSIONS", + &[ + (Act(&[QuickPrompt]), "quick prompt: Enter launches"), + (Act(&[New]), "new, per column"), + (Act(&[DuplicateSession]), "quick prompt as this session"), + (Act(&[AgentPresets]), "agent presets: saved launches"), + ( + Act(&[NewTerminal, OpenGhosttyTab]), + "terminal: here / in Ghostty", + ), + (Act(&[Rename]), "rename the session"), + (Act(&[Archive, Unarchive]), "archive / unarchive"), + (Act(&[Delete, DeleteAll]), "delete one / delete all"), + ], + ), + ( + "TERMINAL & MOUSE", + &[ + (Act(&[Activate]), "lock input"), + (Act(&[UnlockTerminal]), "unlock, back to the column"), + (Lit("drag"), "select + copy (2×click: word)"), + (Lit("⌥click"), "open URL / file under cursor"), + (Lit("⇧drag"), "select via your terminal"), + (Lit("right-click"), "row menu: run, restart"), + (Lit("click outside"), "dismiss any modal (= Esc)"), + ], + ), + ( + "GENERAL", + &[ + (Lit("⇧ + letter"), "bigger, or outside nebula"), + (Act(&[Hosts]), "ssh hosts (a: new, d: del)"), + (Act(&[Settings]), "settings; Hotkeys tab rebinds"), + (Act(&[Metrics]), "memory: nebula + agents"), + (Act(&[Quit, Help]), "quit / toggle this help"), + ], + ), + ]; + (LEFT, RIGHT) +} + +/// PROJECTS: every project on the machine, the one last worked in first — +/// what the PROJECT TABS are in the GRID, with the cursor's row the lit +/// tab. +fn draw_projects(f: &mut Frame, app: &mut App, area: Rect) { + let th = app.theme; + let count = app.project_rows().len(); + let list = column(f, area, "PROJECTS", count, app.focus == Focus::Projects, th); + let lines = crate::panels::project_lines(app); + let cursor = Some(Row::Project(app.sel_project)); + let rows = app.project_rows(); + let now = crate::app::now_ms(); + draw_list( + f, + app, + area, + list, + &lines, + cursor, + Focus::Projects, + |app, at, width| { + let Row::Project(i) = at else { + return (Vec::new(), th.accent); + }; + let Some(p) = rows.get(i).and_then(|i| app.tree.projects.get(*i)) else { + return (Vec::new(), th.accent); + }; + let roll = crate::app::project_rollup(&app.tree, &p.id); + let unseen = crate::app::project_unseen(&app.tree, &p.id); + let fresh = crate::panels::project_fresh_done(app, &p.id); + let stamped = crate::app::project_recency(&app.tree, &p.id, now).stamped; + let badge = unseen_badge(unseen); + let free = width.saturating_sub(3 + badge.chars().count()); + let (ago, name_max) = fit_ago(ago_badge(stamped), free); + let mut spans = vec![status_dot(roll, unseen > 0, th)]; + spans.extend(status_name_spans( + truncate(&p.name, name_max), + Style::default().add_modifier(Modifier::BOLD), + sweep_ramp(roll, fresh, th, app.animations), + app.sweep_phase(), + )); + push_dim(&mut spans, ago, th); + spans.push(Span::styled(badge, Style::default().fg(th.done))); + (spans, status_color(roll, unseen > 0, th)) + }, + ); +} + +/// WORKTREES: the selected project's checkouts, then its open pull +/// requests and issues under the headers that fold them +/// (`panels::worktree_lines`). +fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { + let th = app.theme; + let count = app.visible_worktrees().len(); + let list = column( + f, + area, + "WORKTREES", + count, + app.focus == Focus::Worktrees, + th, + ); + // The page Ctrl+d / Ctrl+u halve. + app.worktrees_view_rows = usize::from(list.height); + let lines = crate::panels::worktree_lines(app); + let cursor = Some(Row::Worktree(app.sel_worktree)); + let now = crate::app::now_ms(); + draw_list( + f, + app, + area, + list, + &lines, + cursor, + Focus::Worktrees, + |app, at, width| { + let Row::Worktree(i) = at else { + return (Vec::new(), th.accent); + }; + match app.worktree_rows().get(i).copied() { + Some(WorktreeRow::Checkout(w)) => checkout_row(app, w, false, width, now), + Some(WorktreeRow::PrCheckout(w)) => checkout_row(app, w, true, width, now), + Some(WorktreeRow::Pr(pr)) => { + let trouble = pr.trouble(); + let look = crate::pr_row::look(pr.standing(), trouble, th); + let badge = match trouble { + Some(trouble) => Some((format!(" {}", trouble.badge()), look.badge)), + None => pr + .is_draft + .then(|| (format!(" {}", pr.badge()), look.badge)), + }; + let spans = crate::pr_row::spans(look, &pr.label(), width, badge); + (spans, look.rail) + } + Some(WorktreeRow::Issue(issue)) => { + // The green the ISSUES MODAL paints `open` in. + let look = crate::pr_row::Look { + glyph: th.ok, + label: th.muted, + rail: th.ok, + badge: th.dim, + }; + ( + crate::pr_row::spans(look, &issue.label(), width, None), + look.rail, + ) + } + None => (Vec::new(), th.accent), + } + }, + ); +} + +/// One checkout's row: the rolled-up STATUS DOT of its sessions, its +/// branch — under a `└` when it is a pull request's — the RUNNING badge +/// while its RUN COMMAND is up, `⌂ root` on the ROOT WORKTREE, how long +/// since anything in it moved, and the finishes in it nobody has read. +fn checkout_row( + app: &App, + w: &nebula_core::Worktree, + nested: bool, + width: usize, + now: i64, +) -> (Vec>, Color) { + let th = app.theme; + let dim = Style::default().fg(th.dim); + let pending = app.is_placeholder_worktree(&w.id); + let roll = if pending { + None + } else { + crate::app::worktree_rollup(&app.tree, &w.id) + }; + let unseen = crate::app::worktree_unseen(&app.tree, &w.id); + let fresh = crate::panels::worktree_fresh_done(app, &w.id); + let badge = unseen_badge(unseen); + let indent = if nested { NESTED_INDENT } else { "" }; + let run = app.worktree_running(&w.id).then_some(RUN_BADGE); + let free = width.saturating_sub( + 3 + badge.chars().count() + indent.chars().count() + run.map_or(0, |r| r.chars().count()), + ); + let ago = if pending { + PENDING_WORKTREE_BADGE.to_string() + } else { + ago_badge(crate::app::worktree_recency(&app.tree, &w.id, now).stamped) + }; + let (ago, free) = fit_ago(ago, free); + let fits = |b: &str| w.branch.chars().count() + b.chars().count() <= free; + let root = if !w.is_main { + None + } else if fits(ROOT_BADGE) { + Some(ROOT_BADGE) + } else if fits(ROOT_GLYPH) { + Some(ROOT_GLYPH) + } else { + None + }; + let max = free.saturating_sub(root.map_or(0, |r| r.chars().count())); + let mut spans = Vec::new(); + if nested { + spans.push(Span::styled(indent, dim)); + } + spans.push(status_dot(roll, unseen > 0, th)); + spans.extend(status_name_spans( + truncate(&w.branch, max), + Style::default().fg(th.text), + sweep_ramp(roll, fresh, th, app.animations), + app.sweep_phase(), + )); + if let Some(run) = run { + spans.push(Span::styled( + run, + Style::default().fg(th.ok).add_modifier(Modifier::BOLD), + )); + } + if let Some(root) = root { + spans.push(Span::styled(root, dim)); + } + push_dim(&mut spans, ago, th); + spans.push(Span::styled(badge, Style::default().fg(th.done))); + (spans, status_color(roll, unseen > 0, th)) +} + +/// SESSIONS: the selected checkout's sessions, terminals, pull request and +/// archived sessions under their headers (`panels::session_lines`). A +/// checkout with nothing in it says which keys start something; a pull +/// request or an issue under the WORKTREES cursor has no sessions, and the +/// column is left empty while the pane reads it. +fn draw_sessions(f: &mut Frame, app: &mut App, area: Rect) { + let th = app.theme; + let rows = app.visible_session_rows(); + let count = rows.iter().filter(|r| r.as_link().is_none()).count(); + let list = column(f, area, "SESSIONS", count, app.focus == Focus::Sessions, th); + app.sessions_view_rows = usize::from(list.height); + let lines = crate::panels::session_lines(app); + if lines.is_empty() && app.selected_worktree().is_some() { + let key = Style::default().fg(th.accent); + let dim = Style::default().fg(th.dim); + let hint = Line::from(vec![ + Span::raw(ROW_GUTTER), + Span::styled(key_hint(app, Action::New), key), + Span::styled(" agent · ", dim), + Span::styled(key_hint(app, Action::NewTerminal), key), + Span::styled(" terminal", dim), + ]); + f.render_widget(Paragraph::new(hint), list); + } + let cursor = Some(Row::Session(app.sel_session)); + let mut cfg: Option = None; + draw_list( + f, + app, + area, + list, + &lines, + cursor, + Focus::Sessions, + |app, at, width| { + let Row::Session(i) = at else { + return (Vec::new(), th.accent); + }; + match rows.get(i) { + Some(SessionRow::Agent(a)) => agent_row(app, a, width, &mut cfg), + Some(SessionRow::Terminal(t)) => terminal_row(t, width, th), + Some(SessionRow::Link(l)) => { + let pr = l.pull_request(); + let look = match pr { + Some(pr) => crate::pr_row::look(pr.standing(), pr.trouble(), th), + None => crate::pr_row::Look { + glyph: th.muted, + label: th.muted, + rail: th.accent, + badge: th.dim, + }, + }; + let badge = pr.map(|pr| match pr.trouble() { + Some(trouble) => (format!(" {}", trouble.badge()), look.badge), + None => (format!(" {}", pr.standing().badge()), look.badge), + }); + ( + crate::pr_row::spans(look, &l.label(), width, badge), + look.rail, + ) + } + None => (Vec::new(), th.accent), + } + }, + ); +} + +/// A session's row: its STATUS DOT — gray while no PTY is behind it (the +/// IDLE REAPER took it), hollow while it is a stand-in still being +/// created, `⊘` once archived — its name, how long since it moved, and the +/// harness it runs on while the name has room beside it; an unread finish +/// takes the harness's slot as ` done`, a Claude Cloud row says ` cloud`. +fn agent_row( + app: &App, + a: &nebula_core::Agent, + width: usize, + cfg: &mut Option, +) -> (Vec>, Color) { + let th = app.theme; + let pending = app.is_placeholder_agent(&a.id); + let cold = !a.alive && a.cloud_session_id.is_none(); + let dot = if a.archived { + Span::styled("⊘ ", Style::default().fg(th.dim)) + } else if pending { + status_dot(None, false, th) + } else if cold { + Span { + style: Style::default().fg(th.dim), + ..status_dot(Some(a.status), false, th) + } + } else { + status_dot(Some(a.status), a.unseen, th) + }; + let ago = if pending { + String::new() + } else { + ago_badge(a.status_changed_at) + }; + let (badge, badge_color) = if pending { + (PENDING_SESSION_BADGE.to_string(), th.dim) + } else if a.unseen && !a.archived { + (" done".to_string(), th.done) + } else if a.cloud_session_id.is_some() { + (" cloud".to_string(), th.dim) + } else { + // The harness is the one thing on the row it can do without: on + // a column too narrow for the whole name and its age beside it, + // it goes first. + let harness = if a.kind == nebula_core::AgentKind::Custom { + let cfg = cfg.get_or_insert_with(crate::config::Config::load); + crate::agent_picker::session_harness_badge_in(a, cfg) + } else { + a.kind.as_str().to_string() + }; + let room = width.saturating_sub(3 + 1 + harness.chars().count() + ago.chars().count()); + if a.name.chars().count() <= room { + (format!(" {harness}"), th.dim) + } else { + (String::new(), th.dim) + } + }; + let free = width.saturating_sub(3 + badge.chars().count()); + let (ago, name_max) = fit_ago(ago, free); + let quiet = a.archived || pending || cold; + let ramp = if quiet { + None + } else { + sweep_ramp(Some(a.status), app.agent_fresh_done(a), th, app.animations) + }; + let name_style = Style::default().fg(if a.archived { th.dim } else { th.text }); + let mut spans = vec![dot]; + spans.extend(status_name_spans( + truncate(&a.name, name_max), + name_style, + ramp, + app.sweep_phase(), + )); + push_dim(&mut spans, ago, th); + spans.push(Span::styled(badge, Style::default().fg(badge_color))); + let mark = if quiet { + th.dim + } else { + status_color(Some(a.status), a.unseen, th) + }; + (spans, mark) +} + +/// A terminal's row: the shell's `❯` — a RUN TERMINAL's `▶` and its +/// command after the name — green while the shell is up, dim once it has +/// exited. +fn terminal_row( + t: &nebula_core::TerminalTab, + width: usize, + th: Theme, +) -> (Vec>, Color) { + let glyph = if t.run_command.is_some() { + "▶ " + } else { + "❯ " + }; + let color = if t.alive { th.ok } else { th.dim }; + let name = truncate(&t.name, width.saturating_sub(3)); + let room = width.saturating_sub(4 + name.chars().count()); + let mut spans = vec![ + Span::styled(glyph, Style::default().fg(color)), + Span::styled(name, Style::default().fg(th.text)), + ]; + if let Some(command) = t.run_command.as_deref().filter(|_| room > 1) { + spans.push(Span::styled( + format!(" {}", truncate(command, room)), + Style::default().fg(th.dim), + )); + } + (spans, th.accent) +} + +/// ` 2 done`: the finishes under a project or checkout nobody has read, +/// in the color the session rows' own ` done` wears. Empty with none. +fn unseen_badge(unseen: usize) -> String { + if unseen == 0 { + String::new() + } else { + format!(" {unseen} done") + } +} + +/// `text` as a dim span, when there is any. +fn push_dim(spans: &mut Vec>, text: String, th: Theme) { + if !text.is_empty() { + spans.push(Span::styled(text, Style::default().fg(th.dim))); + } +} diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index 8b6dc18c..f8145065 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -55,6 +55,16 @@ impl TuiHarness { /// a stub `gh` on PATH so the pull-request row can be driven without a /// GitHub account. fn spawn_with_env(extra_env: &[(&str, String)]) -> Self { + Self::spawn_with(extra_env, None) + } + + /// `spawn`, with `config` written to CONFIG.JSON before the TUI starts + /// — a setting the run needs from its first frame. + fn spawn_with_config(config: &str) -> Self { + Self::spawn_with(&[], Some(config)) + } + + fn spawn_with(extra_env: &[(&str, String)], config: Option<&str>) -> Self { // Socket paths must stay under SUN_LEN (~104 bytes) — keep the // runtime dir short. Tests share one process, so a per-harness // sequence keeps each test on its own daemon. @@ -65,6 +75,10 @@ impl TuiHarness { let data_dir = PathBuf::from(format!("/tmp/nebtui-data-{pid}-{seq}")); let _ = std::fs::remove_dir_all(&runtime_dir); let _ = std::fs::remove_dir_all(&data_dir); + if let Some(config) = config { + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join("config.json"), config).unwrap(); + } let repos = tempfile::tempdir().unwrap(); let pty = native_pty_system() @@ -955,3 +969,174 @@ fn tui_drag_past_the_pane_top_autoscrolls_and_copies_the_run() { assert_eq!(*row, format!("row {}", first + i), "{text}"); } } + +// ---- the PANELS (Settings › Appearance › Layout `panels`) ---- + +/// The PANELS' footer while each column has the keys — the first words of +/// each column's own hints. +const FOOTER_PROJECTS: &str = "n/o: add"; +const FOOTER_WORKTREES: &str = "n: new worktree"; +const FOOTER_SESSIONS: &str = "Enter: focus n: agent"; +const LEFT: &[u8] = b"\x1b[D"; + +/// Does the SESSIONS column — the third, at the PANELS' default widths — +/// show `needle`? The pane beside it names the attached session too, so a +/// whole-screen search can't tell a row from the pane's header. +fn sessions_column_contains(screen: &vt100::Screen, needle: &str) -> bool { + const SESSIONS_X: u16 = 20 + 22; + const SESSIONS_W: u16 = 32; + let (rows, cols) = screen.size(); + let right = SESSIONS_X.saturating_add(SESSIONS_W).min(cols); + (0..rows).any(|row| { + let line: String = (SESSIONS_X..right) + .map(|col| { + let c = screen + .cell(row, col) + .map(|c| c.contents()) + .unwrap_or_default(); + if c.is_empty() { + " ".to_string() + } else { + c.to_string() + } + }) + .collect(); + line.contains(needle) + }) +} + +/// The PANELS walked the way the three-column layout always was: `h`/`l` +/// across PROJECTS | WORKTREES | SESSIONS, `j`/`k` down each, Enter +/// drilling in, a project scoping the worktrees and a worktree the +/// sessions, a session started from SESSIONS taking the pane and `^q` +/// handing the keys back. +#[test] +fn tui_panels_walk_projects_worktrees_and_sessions() { + let mut tui = TuiHarness::spawn_with_config(r#"{"layout": "panels"}"#); + let alpha = tui.make_repo("alpha-proj"); + let beta = tui.make_repo("beta-proj"); + tui.wait_for_text("create your first project"); + add_project(&mut tui, &alpha, "alpha-proj"); + add_project(&mut tui, &beta, "beta-proj"); + for column in ["PROJECTS", "WORKTREES", "SESSIONS", "TERMINAL"] { + tui.wait_for_text(column); + } + + // ---- h walks left to PROJECTS and stops there; j/k walk projects ---- + for _ in 0..3 { + tui.send(b"h"); + } + tui.wait_for_text(FOOTER_PROJECTS); + // Neither project has run anything, so they list in the order added. + tui.send(b"k"); + tui.wait_for_selected("alpha-proj"); + tui.send(b"j"); + tui.wait_for_selected("beta-proj"); + tui.send(b"k"); + tui.wait_for_selected("alpha-proj"); + + // ---- Enter drills in: PROJECTS → WORKTREES; a new worktree there ---- + tui.send(ENTER); + tui.wait_for_text(FOOTER_WORKTREES); + tui.wait_for_text("main ⌂ root"); + tui.send(b"n"); + tui.wait_for_text("New worktree"); + tui.type_str("feat-a"); + tui.send(ENTER); + tui.wait_for_gone("New worktree"); + tui.wait_for_text("feat-a"); + tui.wait_for_selected("feat-a"); + + // ---- SESSIONS: n picks a harness, the launch takes the pane ---- + tui.send(b"l"); + tui.wait_for_text(FOOTER_SESSIONS); + tui.send(b"n"); + tui.wait_for_text("New session"); + tui.send(ENTER); + tui.wait_for_gone("New session"); + tui.wait_for("agent-1 in the SESSIONS column", |s| { + sessions_column_contains(s, "agent-1") + }); + tui.wait_for_text(FOOTER_TERMINAL_LOCKED); + tui.send(CTRL_Q); + tui.wait_for_text(FOOTER_SESSIONS); + // Tab walks onto the live pane and takes its input in one step. + tui.send(TAB); + tui.wait_for_text(FOOTER_TERMINAL_LOCKED); + tui.send(CTRL_Q); + tui.wait_for_text(FOOTER_SESSIONS); + + // ---- sessions are per-worktree: the root has no agent-1 ---- + tui.send(LEFT); + tui.wait_for_text(FOOTER_WORKTREES); + tui.send(b"k"); + tui.wait_for_selected("main ⌂ root"); + tui.wait_for("agent-1 gone from the SESSIONS column", |s| { + !sessions_column_contains(s, "agent-1") + }); + tui.send(b"j"); + tui.wait_for_selected("feat-a"); + tui.wait_for("agent-1 back in the SESSIONS column", |s| { + sessions_column_contains(s, "agent-1") + }); + + // ---- another project swaps the WORKTREES column ---- + tui.send(b"h"); + tui.wait_for_text(FOOTER_PROJECTS); + tui.send(b"j"); + tui.wait_for_selected("beta-proj"); + tui.wait_for_gone("feat-a"); + + // ---- a click on a row selects it and its column takes the keys ---- + let (row, col) = + find_text(tui.parser.lock().unwrap().screen(), "alpha-proj").expect("alpha-proj on screen"); + tui.send(&sgr_mouse(0, col, row, false)); + tui.send(&sgr_mouse(0, col, row, true)); + tui.wait_for_selected("alpha-proj"); + tui.wait_for_text("feat-a"); +} + +/// Settings › Appearance › Layout flips between the GRID and the PANELS +/// live, with no restart, and back. +#[test] +fn tui_layout_setting_switches_grid_and_panels_live() { + let mut tui = TuiHarness::spawn(); + let repo = tui.make_repo("layout-proj"); + tui.wait_for_text("create your first project"); + add_project(&mut tui, &repo, "layout-proj"); + assert!(!tui.screen_text().contains("WORKTREES"), "the grid first"); + + let flip = |tui: &mut TuiHarness| { + tui.send(b"s"); + tui.wait_for_text("Appearance"); + // Along the tab strip to Appearance, then down its rows to Layout. + for _ in 0..12 { + if tui.try_wait_for_text("Color theme", Duration::from_millis(300)) { + break; + } + tui.send(TAB); + } + for _ in 0..12 { + let deadline = Instant::now() + Duration::from_millis(400); + while Instant::now() < deadline { + if row_is_selected(tui.parser.lock().unwrap().screen(), "Layout") { + tui.send(ENTER); + tui.send(ESC); + tui.wait_for_gone("Color theme"); + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + tui.send(b"j"); + } + panic!( + "the Layout row never came under the cursor:\n{}", + tui.screen_text() + ); + }; + flip(&mut tui); + tui.wait_for_text("WORKTREES"); + tui.wait_for_text("main ⌂ root"); + flip(&mut tui); + tui.wait_for_gone("WORKTREES"); +} diff --git a/docs/configuration.md b/docs/configuration.md index b3e6d58a..276094c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,6 +78,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `hide_card_marks` | bool | `false` | Appearance | **Card marks** (`shown` / `hidden`): leave the `▶` (a RUN TERMINAL) and the `❯` (a plain shell) off the front of each terminal card's name on the GRID, and the `›` off the front of each session card's last prompt, so the name or prompt starts where the mark was. The pane's title still shows the terminal glyph. Off keeps them. A config written under the old key, `hide_terminal_glyphs`, still reads. | | `highlight_current_card` | bool | `true` | Appearance | **Highlight current card** (`on` / `off`): the card under the cursor on the GRID, the one the pane reads, trades its gray fill for a very faint wash of the colour its frame would have unselected (red asking, yellow running, blue finished and unread). The wash breathes slowly while the card has something going on; a quiet card or a terminal gets a still, faint accent wash, as every card does with the animations off. It stays lit while you type in the pane and fades further when the PROJECT TABS have the keys. Off keeps the plain gray fill, dimmed whenever the keys leave the grid. | | `session_pane` | string | `"right"` | Appearance | Where the PANE that reads the card under the cursor sits: `right` (down the right of the cards, full height, half the width until its edge is dragged) or `bottom` (under the GRID, full width). The SIDE BUTTON just before the `×` on the pane's header flips it in one click — `⬓` down the right, `◨` along the bottom — and writes this same key. Its edge facing the cards is dragged the same way on both sides — a `┃` grip beside the cards, a `━` grip under them — and the width and the height are remembered apart, so switching sides never turns one into the other. A window too narrow for the pane and a column of cards side by side lays it out along the bottom until there is room (and draws no side button). Anything off the list — `left` from older builds included — reads as `right`. | +| `layout` | string | `"grid"` | Appearance | What the body draws: `grid` (the PROJECT TABS over the lit project's GRID of session cards, with the pane beside them) or `panels` (the three-column layout from before the grid — PROJECTS, WORKTREES and SESSIONS side by side, each a list with a status dot per row, and the TERMINAL PANE beside them reading the session under the SESSIONS cursor; see [Keys](keys.md#the-panels) and [Sessions](sessions.md#the-panels)). Switching applies at once and keeps the selection and the attached session. Anything off the list reads as `grid`. | | `worktree_layout` | string | `"cards"` | Appearance | How the GRID lays out each worktree's band: `cards` (a row of cards under the band's rule) or `list` (a compact list — every session and terminal one line under the rule, stacked: its status dot and name, what it runs on, and its last prompt or the shell's last line, with how long since it moved at the right). A band in the list starts collapsed, showing only its 3 most recent sessions — plus the one the cursor is on, wherever it sits — and a `▾ 2 more · Tab: see all 5` line under them; `Tab` (or a click on that line) opens the band to every entry, and `Tab` or `Esc` folds it back. `j`/`k` walk the lines as one column across the bands. Anything off the list reads as `cards`. | | `expand_all_worktrees` | bool | `false` | Appearance | **Expand all worktrees** (`on` / `off`): lay every band on the GRID out open at once — each worktree's sessions and terminals wrapped into rows under its rule (in the `list` layout, every entry listed) — instead of one band opened at a time with `Tab`. With it on there is no accordion: `Tab` and a second click on a band's rule open and fold nothing (the footer says so), and `j`/`k` walk down every worktree's rows as one column. The band `Tab` last opened is kept and is open again once this is off. | | `hide_card_prompt` | bool | `false` | — (retired) | Through 0.40, **Card prompt** (Settings → Appearance, `shown` / `hidden`): `hidden` left the last prompt off every session card on the GRID. Every card shows it now, so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | @@ -197,7 +198,7 @@ next ATTACH or prewarm, and an agent RESUMES its conversation there. - **Settings live in one JSON file** (`config.json`, beside the database, with `config.local.json` over it), read fresh on each use by both the daemon and the TUI, so hand edits apply without a restart. `s` opens the settings overlay over the same file: color theme, animations, which side of the cards the session pane sits on - (`session_pane`), whether each worktree is a row of cards or a compact list (`worktree_layout`), whether every worktree is open at once (`expand_all_worktrees`), + (`session_pane`), the grid or the three panels (`layout`), whether each worktree is a row of cards or a compact list (`worktree_layout`), whether every worktree is open at once (`expand_all_worktrees`), editor, the branch new worktrees start from (`worktree_base_branch`: `auto` for origin's default branch, or a name such as `master`, typed into a prompt that `Enter` opens on the row), which agent CLIs the new-session menu offers (at least one stays on) and their default model diff --git a/docs/keys.md b/docs/keys.md index 761cc398..b2fb5025 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -118,6 +118,29 @@ the pane reads (`a`, `d`, `g`, `/`, `s`, `?`, `q`). `^P` in the box is the way t box at any project on the machine without taking you there, so Enter starts that session in the background and leaves the screen on the work in front of you. +## The panels + +With **Layout** set to `panels` (Settings → Appearance, `layout` in [Configuration](configuration.md)) the +body is the three-column layout from before the grid: PROJECTS | WORKTREES | SESSIONS down the left and +the TERMINAL PANE beside them, reading the session under the SESSIONS cursor. The columns are the +grid's own selection, so switching layouts lands on the same project, checkout and session. The +PROJECTS column lists every project and stands in for the PROJECT TABS. + +| Key | Action | +|---|---| +| `h`/`l` or `←`/`→`, `Tab` | move FOCUS across the columns; `h` stops at PROJECTS, a double `l` at SESSIONS (or `Tab`) crosses into the pane and takes its input | +| `j`/`k` or `↓`/`↑`, `Ctrl+d`/`Ctrl+u` | move the focused column's cursor: a project scopes the WORKTREES, a checkout the SESSIONS, and a session comes up in the pane | +| `Enter` | drill in: PROJECTS → WORKTREES → SESSIONS; on a session, attach it and type into it; on a pull request or an issue, open it in the browser | +| `n` | per column: add a project, cut a worktree, or pick a harness for a new session | +| `p`, `t`, `r`, `a`, `u`, `d`, `/`, `.`/`,`, `g`, `f`, `?` … | as everywhere else, on the selected project, checkout or session | +| `Space` | on a session: its FOLLOW-UP MODAL | +| `^F` | the session under the cursor full-screen; `^F` or `^q` comes back down to the panels' pane | +| `Shift+A` | open or fold the SESSIONS column's ARCHIVED group | +| `]` / `[` | the attention walk, as `.` / `,` | +| `x`, `1`–`9`, `+`, `` ` ``, `` ^` `` | grid-only — the PROJECT TABS, the pane's terminal strip and its fold have nothing to act on here, and the footer says so | +| click | a row selects it and its column takes the keys, a second click is `Enter` on it; a click on a group header (`OPEN PRS`, `ISSUES`, `ARCHIVED`) folds it; a click on the pane steps into it | +| wheel | over a column longer than the screen: scroll it under the cursor, which stays put; the next key that moves the cursor brings it back on screen. `?` lists the panels' keys while they are up | + ## Chips and readouts Two strips report state without being asked. The TERMINAL PANE's header shows one chip at a diff --git a/docs/sessions.md b/docs/sessions.md index b393ac78..1667a780 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -489,6 +489,28 @@ cursor the whole card lifts a step, so the one you are about to unarchive stays selection fill, and with color off entirely the two shapes still tell the grids apart. No card says the word `archived`: the header says it once, for all of them. +## The PANELS + +Settings → Appearance → **Layout** `panels` puts the three-column layout back in place of the GRID: +PROJECTS, WORKTREES and SESSIONS side by side, and the TERMINAL PANE beside them. Every launch path +above works from it, on the selection the columns show. + +- **PROJECTS** lists every project on the machine, the one last worked in first, each with the + rolled-up status dot of its sessions, how long since anything in it moved and its unread finishes. +- **WORKTREES** lists the selected project's checkouts — the root first, `⌂ root` — then the + project's open pull requests under `OPEN PRS` (a checkout on a pull request's branch nested under + it with a `└`) and its open issues under `ISSUES`. A click on either header folds the group. Resting + on a pull request or an issue reads it in the pane. +- **SESSIONS** lists the selected checkout's sessions under `RECENT`, most recently touched first, + then its `TERMINALS`, its `PULL REQUESTS` and the `ARCHIVED` group, folded to its count until + `Shift+A` or a click opens it. Walking the list shows each session in the pane; `Enter` steps + into it. +- `n` in SESSIONS is the NEW SESSION PICKER for the selected checkout, `p` the QUICK PROMPT, `Space` + the FOLLOW-UP MODAL. The breadcrumb in the footer reads `project ▸ worktree ▸ session`. + +The cards' own extras — the last prompt and line counts on a card, the header's PR and issue +counts, the pane's terminal strip — are the grid's and have no column here. + ## The ISSUES MODAL and ISSUE SESSIONS `i` lists the selected PROJECT's open GitHub issues — `gh issue list`, newest first, From 859ea3bcd0c65b91e9ede97a6b0a569ed483a2df Mon Sep 17 00:00:00 2001 From: David Budac Date: Sun, 4 Oct 2026 15:47:54 +0200 Subject: [PATCH 2/5] Dragging a border between the panels' columns resizes the column, and the widths survive a restart - Each column's right border (PROJECTS|WORKTREES, WORKTREES|SESSIONS, SESSIONS|pane) is a drag target: col-resize pointer and a lit grip on hover, a grab offset so the border doesn't jump, no row selected and no FOCUS moved. A column stops at MIN_W, the pane at MIN_PANE_W. - The widths are kept in the saved UI state (`panels_widths`, absent in older blobs); a narrow window still squeezes them for the frame without forgetting them. - The pane's PTY follows the new width through the usual resize. - `?` help, docs/keys.md and docs/sessions.md follow. Co-Authored-By: Claude Opus 5.5 --- crates/nebula-tui/src/app.rs | 47 ++++- crates/nebula-tui/src/event_loop.rs | 33 ++- .../nebula-tui/src/event_loop/focus_walk.rs | 1 + crates/nebula-tui/src/event_loop/panels.rs | 196 +++++++++++++++++- crates/nebula-tui/src/panels.rs | 128 ++++++++++-- crates/nebula-tui/src/ui/panels_view.rs | 46 +++- docs/keys.md | 1 + docs/sessions.md | 2 + 8 files changed, 424 insertions(+), 30 deletions(-) diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 31b30cd1..b942b6fd 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -75,6 +75,11 @@ pub enum HitTarget { /// (`crate::panels::Row`). Registered ahead of its column's /// `PanelBg`, so it wins. PanelsRow(crate::panels::Row), + /// The draggable BORDER on a PANELS column's right — its rule and the + /// cell after it — by the column's index, PROJECTS first + /// (`panels::Columns::grab_zone`). Registered ahead of the rows, so a + /// grab there never selects one. + PanelsBorder(usize), TerminalPane, /// The session URL on the CLOUD SESSION PANEL; a click opens it in the /// browser. Registered ahead of the pane it sits on, so it wins. @@ -2858,6 +2863,11 @@ pub struct UiState { /// blobs, which open with it showing. #[serde(default)] pub launcher_pane_hidden: bool, + /// Widths the PANELS' columns were dragged to; absent in older blobs, + /// and None in ones written before a BORDER was ever dragged, both of + /// which open the columns at `panels::WIDTHS`. + #[serde(default)] + pub panels_widths: Option<[u16; 3]>, /// The band left open as the ACCORDION /// ([`App::launcher_expanded`]), by its worktree id; absent, or None, /// in blobs saved before there was one, which open with every band @@ -3305,6 +3315,22 @@ pub struct App { /// column under its cursor, which stays put, and a move of the /// cursor brings it back on screen. Drawn by `ui::panels_view`. pub panels_scroll: [crate::panels::ColumnScroll; 3], + /// Widths the PANELS' columns were dragged to by their BORDERS — + /// PROJECTS, WORKTREES, SESSIONS — in columns; None until one is, + /// which opens them at `panels::WIDTHS`. Re-fitted to the body on + /// every draw (`panels::columns`) without being rewritten, so widths + /// kept from a wider window come back with it. Remembered across + /// restarts. + pub panels_widths: Option<[u16; 3]>, + /// In-progress drag of a PANELS column's BORDER: the column, and + /// `border column - grab column` at mouse-down, so the border tracks + /// the pointer instead of jumping by one depending on which of the two + /// grab cells was caught (`event_loop::panels::grab_border`). + pub panels_drag: Option<(usize, i32)>, + /// The column whose BORDER is under the mouse, or being dragged: its + /// grip lights up. Carries `hover_launcher_pane`'s caveat — only + /// terminals that report plain motion set it before a drag. + pub hover_panels_border: Option, /// Every BAND is laid out open at once — its cards wrapped into rows, /// or every entry of the LIST listed — and there is no ACCORDION: /// Settings → Appearance → **Expand all worktrees** @@ -3880,6 +3906,9 @@ impl App { launcher_list: false, panels: false, panels_scroll: Default::default(), + panels_widths: None, + panels_drag: None, + hover_panels_border: None, launcher_all_open: false, launcher_pane_hidden: false, launcher_expanded: None, @@ -4520,16 +4549,18 @@ impl App { /// Is the left button down, as far as nebula knows — a press came and /// its release has not: a panel splitter being dragged (the LAUNCHER - /// VIEW's pane edge, the diff and tree modals' file-list border), a - /// program in the pane holding the button, or a drag-selection under - /// way? While it is, the host terminal is left exactly as it is: - /// re-asking it for its modes mid-drag is a change under a gesture in - /// progress, and xterm.js (`nebula browser`) takes the `?1000h` in - /// that re-ask as the end of the drag — it drops its motion listener - /// and reports nothing more until the next press. A motion report - /// with no button named is still the drag. + /// VIEW's pane edge, a PANELS column's BORDER, the diff and tree + /// modals' file-list border), a program in the pane holding the + /// button, or a drag-selection under way? While it is, the host + /// terminal is left exactly as it is: re-asking it for its modes + /// mid-drag is a change under a gesture in progress, and xterm.js + /// (`nebula browser`) takes the `?1000h` in that re-ask as the end of + /// the drag — it drops its motion listener and reports nothing more + /// until the next press. A motion report with no button named is + /// still the drag. pub fn mouse_held(&self) -> bool { let splitter = self.launcher_pane_drag.is_some() + || self.panels_drag.is_some() || match &self.overlay { Some(Overlay::Diff(view)) => view.files_drag.is_some(), Some(Overlay::Tree(view)) => view.files_drag.is_some(), diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index a514a1a7..470f2630 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -2191,6 +2191,7 @@ fn ui_state_json(app: &App) -> String { launcher_pane_h: app.launcher_pane_h, launcher_pane_w: app.launcher_pane_w, launcher_pane_hidden: app.launcher_pane_hidden, + panels_widths: app.panels_widths, launcher_expanded: app.launcher_expanded.as_ref().map(|w| w.to_string()), launcher_open_bands: saved_open_bands(app), launcher_tabs: app.launcher_tabs.iter().map(|id| id.to_string()).collect(), @@ -2248,6 +2249,10 @@ fn restore_ui_state(app: &mut App, json: &str) -> bool { app.launcher_pane_w = state .launcher_pane_w .map(|w| w.clamp(crate::launcher::PANE_MIN_W, MAX_RESTORED_WIDTH)); + // The PANELS' columns the same way (`panels::columns` re-fits them). + app.panels_widths = state + .panels_widths + .map(|w| w.map(|w| w.clamp(crate::panels::MIN_W, MAX_RESTORED_WIDTH))); // A pane folded away with `^~` stays folded across a restart, as its // height does. Nothing is unselected on the way back in: the restore // lands on the cards either way. @@ -9019,6 +9024,22 @@ fn update_pointer(app: &mut App, mouse: &MouseEvent) { app.hover_launcher_pane = on_pane_edge; app.dirty = true; } + // A PANELS column's BORDER: the column held, or the one under it. + let on_border = if on_panels { + app.panels_drag.map(|(i, _)| i).or(match &hit { + Some(HitTarget::PanelsBorder(i)) => Some(*i), + _ => None, + }) + } else { + None + }; + if on_border.is_some() { + app.pointer_shape = PointerShape::ColResize; + } + if app.hover_panels_border != on_border { + app.hover_panels_border = on_border; + app.dirty = true; + } // The header's PROJECT TABS (each tab, its `×`, the `+` after them) // and a full-screen session's `‹ sessions` are the other things on // the main screen a click acts on without the cursor moving there @@ -9655,6 +9676,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // FOCUS; a second click is Enter on it. A group header // folds its group. Some(HitTarget::PanelsRow(row)) => panels::click_row(app, row, out), + // A PANELS column's BORDER: a resize drag armed, and + // nothing selected or focused (`panels::grab_border`). + Some(HitTarget::PanelsBorder(i)) => panels::grab_border(app, i, mouse.column), // The `❮` / `❯` beside a band's row: one card that way // along the band, the very step `h` / `l` take. Some(HitTarget::LauncherStripLeft(i)) => { @@ -9798,6 +9822,8 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // straight away must not snap it to the middle. app.last_pane_edge_click = None; app.dirty = true; + } else if app.panels_drag.is_some() { + panels::move_border(app, mouse.column); } else if let Some(sref) = &app.term_mouse_grab { // The program holding the button gets the motion — if it // asked for motion at all (`?1002h` / `?1003h`); press-only @@ -9819,9 +9845,12 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) } } MouseEventKind::Up(MouseButton::Left) => { - // The pane edge lets go here. + // The pane edge and a PANELS BORDER let go here. They are + // never armed at once, but each is taken on its own so one + // can't strand the other. let pane_ended = app.launcher_pane_drag.take().is_some(); - if pane_ended { + let border_ended = app.panels_drag.take().is_some(); + if pane_ended || border_ended { app.dirty = true; } else if let Some(sref) = app.term_mouse_grab.take() { // The release closes the program's button — except under diff --git a/crates/nebula-tui/src/event_loop/focus_walk.rs b/crates/nebula-tui/src/event_loop/focus_walk.rs index 173032f7..1b3cb753 100644 --- a/crates/nebula-tui/src/event_loop/focus_walk.rs +++ b/crates/nebula-tui/src/event_loop/focus_walk.rs @@ -113,6 +113,7 @@ pub(super) fn land_click_focus(app: &mut App, column: u16, row: u16, out: &mut V // the pane itself takes. Some( HitTarget::LauncherPaneSplitter + | HitTarget::PanelsBorder(_) | HitTarget::LauncherCrumb | HitTarget::LauncherTab(_) | HitTarget::LauncherTabClose(_) diff --git a/crates/nebula-tui/src/event_loop/panels.rs b/crates/nebula-tui/src/event_loop/panels.rs index 75bba5ee..8a53ec10 100644 --- a/crates/nebula-tui/src/event_loop/panels.rs +++ b/crates/nebula-tui/src/event_loop/panels.rs @@ -5,8 +5,9 @@ //! (`move_selection`), Enter into the pane, and every verb that reads the //! selection — so this module only takes the keys the GRID owns and gives //! them their panel meaning, or a word saying they have none here -//! ([`handle_action`]), and translates a click on a row ([`click_row`]) or -//! a notch of the wheel over a column ([`wheel`]). +//! ([`handle_action`]), and translates a click on a row ([`click_row`]), a +//! drag of a column's BORDER ([`grab_border`], [`move_border`]) or a +//! notch of the wheel over a column ([`wheel`]). use super::{ activate, attach_selected, is_double_click, jump_attention, launcher, select_project_row, @@ -185,6 +186,33 @@ pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { } } +/// A press on a PANELS column's BORDER (`HitTarget::PanelsBorder`) at +/// screen column `x`: a resize drag armed, as quietly as the LAUNCHER +/// VIEW's pane edge arms one — no row selected, no FOCUS taken. The offset +/// from the grabbed cell to the border is kept so the border does not jump +/// by one depending on which of its two grab cells was caught; the border +/// is measured by the arithmetic the draw laid it out with. +pub(super) fn grab_border(app: &mut App, column: usize, x: u16) { + let border = crate::panels::columns(app.body_area, app.panels_widths).border(column); + app.panels_drag = Some((column, i32::from(border) - i32::from(x))); +} + +/// The pointer at screen column `x` with a BORDER held: the border follows +/// it (`panels::drag_border`, which holds the column and the pane to their +/// floors) and the width is remembered. The PANE takes up what the columns +/// leave, and its PTY is resized to that once the frame has drawn it +/// (`sync_pty_size`), as it is when the window itself resizes. +pub(super) fn move_border(app: &mut App, x: u16) { + let Some((column, grab)) = app.panels_drag else { + return; + }; + let to = i32::from(x) + grab; + if let Some(widths) = crate::panels::drag_border(app.body_area, app.panels_widths, column, to) { + app.panels_widths = Some(widths); + } + app.dirty = true; +} + /// Lines a notch of the wheel scrolls a PANELS column: a third of the /// GRID's card, as `launcher::GRID_WHEEL_ROWS`. const WHEEL_LINES: isize = 3; @@ -214,8 +242,10 @@ pub(super) fn wheel(app: &mut App, over: Option<&HitTarget>, up: bool) -> bool { #[cfg(test)] mod tests { use super::super::tests::{buffer_text, hse, press, seed_tree, with_config_json}; - use super::super::{apply_config, handle_mouse}; - use crate::app::{App, Focus, HitTarget, Overlay, PromptKind}; + use super::super::{ + apply_config, handle_mouse, restore_ui_state, sync_pty_size, ui_state_json, + }; + use crate::app::{App, AttachedTerm, Focus, HitTarget, Overlay, PointerShape, PromptKind}; use crate::panels::Row; use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use nebula_core::{ @@ -720,6 +750,163 @@ mod tests { assert_eq!(app.focus, Focus::Terminal); } + /// One mouse event at a cell, unmodified. + fn mouse_at( + app: &mut App, + kind: MouseEventKind, + (column, row): (u16, u16), + out: &mut Vec, + ) { + let modifiers = KeyModifiers::NONE; + handle_mouse( + app, + MouseEvent { + kind, + column, + row, + modifiers, + }, + out, + ); + } + + /// Column `i`'s rule, a few rows down: the left of its BORDER's two + /// grab cells. + fn rule_cell(app: &App, i: usize) -> (u16, u16) { + let zone = app + .hits + .iter() + .find(|(_, h)| *h == HitTarget::PanelsBorder(i)) + .map(|(r, _)| *r) + .unwrap_or_else(|| panic!("border {i} is not on screen")); + (zone.x, zone.y + 5) + } + + /// A press on a BORDER arms a drag that keeps its grab offset, moves no + /// cursor or FOCUS and selects nothing; the motion resizes the one + /// column, the release ends it, and the pane — and the PTY in it — + /// take up what the column gave or took. + #[test] + fn dragging_a_border_resizes_its_column_and_the_pane_follows() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Projects; + draw(&mut app); + let sref = app.selected_session_row().and_then(|r| r.sref()).unwrap(); + app.term = Some(AttachedTerm::new(sref, 40, 10)); + draw(&mut app); + sync_pty_size(&mut app, &mut out); + out.clear(); + let pane = app.term_area; + let cursors = (app.sel_project, app.sel_worktree, app.sel_session); + let (x, y) = rule_cell(&app, 1); + let down = MouseEventKind::Down(MouseButton::Left); + mouse_at(&mut app, down, (x, y), &mut out); + assert_eq!(app.panels_drag, Some((1, 1)), "one short of the border"); + assert_eq!(app.pointer_shape, PointerShape::ColResize); + assert_eq!(app.hover_panels_border, Some(1)); + let drag = MouseEventKind::Drag(MouseButton::Left); + mouse_at(&mut app, drag, (x + 8, y), &mut out); + assert_eq!(app.panels_widths, Some([20, 30, 32])); + mouse_at(&mut app, drag, (x + 3, y), &mut out); + assert_eq!(app.panels_widths, Some([20, 25, 32]), "and back"); + assert_eq!( + cursors, + (app.sel_project, app.sel_worktree, app.sel_session) + ); + assert_eq!(app.focus, Focus::Projects, "FOCUS stays"); + assert!(app.term_selection.is_none(), "nothing selected"); + assert!(out.is_empty(), "{out:?}"); + let up = MouseEventKind::Up(MouseButton::Left); + mouse_at(&mut app, up, (x + 3, y), &mut out); + assert!(app.panels_drag.is_none(), "mouse-up ends the drag"); + draw(&mut app); + assert_eq!( + (app.term_area.x, app.term_area.width), + (pane.x + 3, pane.width - 3) + ); + sync_pty_size(&mut app, &mut out); + assert!( + out.iter().any( + |r| matches!(r, ClientRequest::Resize { cols, .. } if *cols == pane.width - 3) + ), + "{out:?}" + ); + } + + /// Dragged off either end, the column rests at its floor and the pane + /// at its own; the drag holds the resize arrows past the grab zone. + #[test] + fn a_border_drag_stops_at_both_floors() { + use crate::panels::{MIN_PANE_W, MIN_W}; + let mut app = panels_app(); + let mut out = Vec::new(); + draw(&mut app); + let body = app.body_area; + let (x, y) = rule_cell(&app, 0); + let down = MouseEventKind::Down(MouseButton::Left); + mouse_at(&mut app, down, (x, y), &mut out); + let drag = MouseEventKind::Drag(MouseButton::Left); + mouse_at(&mut app, drag, (0, y), &mut out); + assert_eq!(app.panels_widths, Some([MIN_W, 22, 32])); + mouse_at(&mut app, drag, (body.right() - 1, y), &mut out); + let [p, w, s] = app.panels_widths.unwrap(); + assert_eq!((w, s), (22, 32), "only the dragged column moved"); + assert_eq!(body.width - (p + w + s), MIN_PANE_W); + assert_eq!(app.pointer_shape, PointerShape::ColResize); + assert_eq!(app.hover_panels_border, Some(0), "the grip stays lit"); + } + + /// The pointer resting on a BORDER asks for the resize arrows and + /// lights that border's grip; moved off, both rest again. + #[test] + fn hovering_a_border_lights_its_grip() { + let mut app = panels_app(); + let mut out = Vec::new(); + draw(&mut app); + let (x, y) = rule_cell(&app, 2); + mouse_at(&mut app, MouseEventKind::Moved, (x + 1, y), &mut out); + assert_eq!(app.pointer_shape, PointerShape::ColResize); + assert_eq!(app.hover_panels_border, Some(2)); + let (terminal, _) = draw(&mut app); + let lit = |i: usize| { + let x = rule_cell(&app, i).0; + let buf = terminal.backend().buffer(); + (0..buf.area.height).any(|y| { + let cell = &buf[(x, y)]; + cell.symbol() == "┃" && cell.fg == app.theme.accent + }) + }; + assert!(lit(2), "the hovered grip is lit"); + assert!(!lit(0) && !lit(1), "the others rest"); + mouse_at(&mut app, MouseEventKind::Moved, (x - 5, y), &mut out); + assert_eq!(app.pointer_shape, PointerShape::Default); + assert_eq!(app.hover_panels_border, None); + } + + /// The widths a drag left outlive a restart; a blob from before there + /// were any opens the columns at their defaults, and a nonsense one is + /// held to sane widths. + #[test] + fn the_dragged_widths_outlive_a_restart() { + let mut app = panels_app(); + app.panels_widths = Some([30, 15, 40]); + let json = ui_state_json(&app); + let mut next = panels_app(); + restore_ui_state(&mut next, &json); + assert_eq!(next.panels_widths, Some([30, 15, 40])); + restore_ui_state(&mut next, r#"{"show_archived":false,"collapsed":false}"#); + assert_eq!(next.panels_widths, None, "an older blob: the defaults"); + restore_ui_state( + &mut next, + r#"{"show_archived":false,"collapsed":false,"panels_widths":[0,9999,40]}"#, + ); + assert_eq!( + next.panels_widths, + Some([crate::panels::MIN_W, super::super::MAX_RESTORED_WIDTH, 40]) + ); + } + /// `?` beside the columns teaches the columns' keys, not the grid's /// cards and project tabs. #[test] @@ -741,6 +928,7 @@ mod tests { "session: follow-up modal", "fold the ARCHIVED group", "project tabs: none here", + "resize the column", ] { assert!(text.contains(want), "{want}: {text}"); } diff --git a/crates/nebula-tui/src/panels.rs b/crates/nebula-tui/src/panels.rs index 1986f00d..db7244d3 100644 --- a/crates/nebula-tui/src/panels.rs +++ b/crates/nebula-tui/src/panels.rs @@ -16,7 +16,8 @@ //! is whichever its cursor is on, which is also the tab the GRID lights. //! //! What lives here is what the layout adds: the columns' widths -//! ([`columns`]), the lines each column lays out ([`project_lines`], +//! ([`columns`]) and the drag of a column's BORDER that changes them +//! ([`drag_border`]), the lines each column lays out ([`project_lines`], //! [`worktree_lines`], [`session_lines`]) and the scroll that keeps the //! cursor's line on screen ([`scroll_to`]) until the wheel moves it //! ([`ColumnScroll`]). The keys are @@ -27,8 +28,8 @@ use nebula_core::{ProjectId, WorktreeId}; use ratatui::layout::{Constraint, Layout, Rect}; /// Widths of the PROJECTS, WORKTREES and SESSIONS columns on a body wide -/// enough for them and the pane: the widths the three panels opened at -/// before they were dragged. +/// enough for them and the pane, until a BORDER is dragged +/// (`App::panels_widths`). pub const WIDTHS: [u16; 3] = [20, 22, 32]; /// Narrowest a column is squeezed to on a narrow body. pub const MIN_W: u16 = 10; @@ -76,10 +77,13 @@ pub enum Line { Row(Row), } -/// The four rects of the PANELS: the three columns at [`WIDTHS`], and the -/// PANE taking every column left over. A body too narrow for that squeezes -/// the columns in proportion — down to [`MIN_W`] each — so the pane keeps -/// [`MIN_PANE_W`] for as long as the window allows. +/// The four rects of the PANELS: the three columns at the widths they were +/// dragged to — [`WIDTHS`] until then — and the PANE taking every column +/// left over. A body too narrow for that squeezes the columns in +/// proportion — down to [`MIN_W`] each — so the pane keeps [`MIN_PANE_W`] +/// for as long as the window allows. The squeeze is the frame's alone: +/// the widths remembered are untouched, so a window that grows back opens +/// the columns back up to them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Columns { pub projects: Rect, @@ -98,17 +102,47 @@ impl Columns { Focus::Terminal => self.pane, } } + + /// The three columns' widths as laid out, PROJECTS first. + pub fn widths(&self) -> [u16; 3] { + [self.projects, self.worktrees, self.sessions].map(|r| r.width) + } + + /// Column `i`'s BORDER: the screen column just past its rule, where the + /// next column — or, past SESSIONS, the pane — starts. + pub fn border(&self, i: usize) -> u16 { + let col = self.column(i); + col.x + col.width + } + + /// Column `i`, PROJECTS first. + fn column(&self, i: usize) -> Rect { + [self.projects, self.worktrees, self.sessions][i] + } + + /// Column `i`'s BORDER as a drag target (`HitTarget::PanelsBorder`): + /// its rule and the cell after it, down the whole body — the two + /// touching cells every splitter grabs by. + pub fn grab_zone(&self, i: usize) -> Rect { + Rect { + x: self.border(i).saturating_sub(1), + width: 2, + ..self.column(i) + } + } } -/// Lay the PANELS out over `body` ([`Columns`]). -pub fn columns(body: Rect) -> Columns { - let want: u16 = WIDTHS.iter().sum(); +/// Lay the PANELS out over `body` ([`Columns`]), the columns at `widths` — +/// [`WIDTHS`] when None. +pub fn columns(body: Rect, widths: Option<[u16; 3]>) -> Columns { + let widths = widths.unwrap_or(WIDTHS); + let want: u16 = widths.iter().sum(); let budget = body.width.saturating_sub(MIN_PANE_W); - let widths = WIDTHS.map(|w| { + let widths = widths.map(|w| { if budget >= want { w } else { - (u32::from(w) * u32::from(budget) / u32::from(want)) as u16 + (u32::from(w) * u32::from(budget) / u32::from(want.max(1))) as u16 } .max(MIN_W) }); @@ -127,6 +161,30 @@ pub fn columns(body: Rect) -> Columns { } } +/// The widths that leave column `i`'s BORDER at screen column `border`, +/// the other two kept as `body` lays them out this frame: the column held +/// to [`MIN_W`] at the narrow end and, at the wide one, to what leaves the +/// pane its [`MIN_PANE_W`]. None on a body with no room to widen or narrow +/// it at all — there is nothing a drag there could remember. +pub fn drag_border( + body: Rect, + widths: Option<[u16; 3]>, + i: usize, + border: i32, +) -> Option<[u16; 3]> { + let cols = columns(body, widths); + let mut widths = cols.widths(); + let left = cols.column(i).x; + let others: u16 = widths.iter().sum::() - widths[i]; + let max = body.width.saturating_sub(others + MIN_PANE_W); + if max < MIN_W { + return None; + } + let want = (border - i32::from(left)).clamp(0, i32::from(u16::MAX)) as u16; + widths[i] = want.clamp(MIN_W, max); + Some(widths) +} + /// The PROJECTS column: one row per project, most recently worked in /// first — `App::project_rows`' own order, which is what `sel_project` /// indexes. @@ -401,7 +459,7 @@ mod tests { /// every column left over. #[test] fn the_columns_open_at_their_widths_and_the_pane_takes_the_rest() { - let c = columns(body(190)); + let c = columns(body(190), None); assert_eq!( [c.projects.width, c.worktrees.width, c.sessions.width], WIDTHS @@ -416,11 +474,53 @@ mod tests { /// pane keeps its own. #[test] fn a_narrow_body_squeezes_the_columns_for_the_pane() { - let c = columns(body(80)); + let c = columns(body(80), None); assert!(c.pane.width >= MIN_PANE_W, "{c:?}"); for w in [c.projects.width, c.worktrees.width, c.sessions.width] { assert!((MIN_W..32).contains(&w), "{c:?}"); } + // Widths dragged wide on a bigger window squeeze the same way. + let c = columns(body(80), Some([60, 60, 60])); + assert!(c.pane.width >= MIN_PANE_W, "{c:?}"); + assert!(c.widths().iter().all(|w| *w >= MIN_W), "{c:?}"); + } + + /// Widths a drag left are the ones laid out, and each BORDER sits + /// just past its column's rule. + #[test] + fn the_columns_open_at_the_widths_they_were_dragged_to() { + let c = columns(body(190), Some([30, 15, 40])); + assert_eq!(c.widths(), [30, 15, 40]); + assert_eq!(c.pane.x, 85); + assert_eq!([c.border(0), c.border(1), c.border(2)], [30, 45, 85]); + assert_eq!(c.grab_zone(2), Rect::new(84, 0, 2, 30)); + } + + /// A drag moves the one column's BORDER, wider or narrower, and the + /// other two keep their widths. + #[test] + fn a_drag_widens_and_narrows_the_one_column() { + let wide = drag_border(body(190), None, 1, 50); + assert_eq!(wide, Some([20, 30, 32])); + let narrow = drag_border(body(190), wide, 2, 70); + assert_eq!(narrow, Some([20, 30, 20])); + } + + /// The column rests at its floor dragged off the left, and dragged + /// off the right it stops where the pane keeps its own. + #[test] + fn a_drag_holds_the_column_and_the_pane_to_their_floors() { + assert_eq!(drag_border(body(190), None, 0, -40), Some([MIN_W, 22, 32])); + let [p, w, s] = drag_border(body(190), None, 1, 500).unwrap(); + assert_eq!((p, s), (20, 32), "only the dragged column moved"); + assert_eq!(190 - (p + w + s), MIN_PANE_W); + } + + /// A body already squeezed to the floors has nothing to drag: the + /// widths stay as they were remembered. + #[test] + fn a_body_with_no_room_remembers_no_drag() { + assert_eq!(drag_border(body(40), None, 0, 30), None); } fn rows(n: usize) -> Vec { diff --git a/crates/nebula-tui/src/ui/panels_view.rs b/crates/nebula-tui/src/ui/panels_view.rs index ecdfac8d..7a2ab6bd 100644 --- a/crates/nebula-tui/src/ui/panels_view.rs +++ b/crates/nebula-tui/src/ui/panels_view.rs @@ -10,7 +10,9 @@ //! //! Every row registers a `HitTarget::PanelsRow` ahead of its column's //! `PanelBg`, so a click lands on the row and a click on the air under the -//! rows only takes FOCUS. +//! rows only takes FOCUS. Each column's BORDER — its rule and the cell +//! after it — registers a `HitTarget::PanelsBorder` ahead of them all, and +//! wears a grip that lights while it is hovered or dragged. use super::{ ago_badge, draw_focus_tint, draw_terminal, fit_ago, key_hint, render_button, row_rect, @@ -40,11 +42,20 @@ const ROOT_GLYPH: &str = " ⌂"; const RUN_BADGE: &str = " ▶"; /// What hangs a checkout under the pull request on its head branch. const NESTED_INDENT: &str = "└"; +/// Rows a BORDER's grip runs down the middle of its rule: the LAUNCHER +/// VIEW's pane grip beside the cards is as tall. +const GRIP_H: u16 = 4; /// The PANELS over `body`: the three columns and the pane beside them. pub(super) fn draw(f: &mut Frame, app: &mut App, body: Rect) { app.body_area = body; - let cols = crate::panels::columns(body); + let cols = crate::panels::columns(body, app.panels_widths); + // The BORDERS first, so they win `hit_at`'s first-match scan against + // the row, or the pane, a grab cell lands on. + for i in 0..3 { + app.hits + .push((cols.grab_zone(i), HitTarget::PanelsBorder(i))); + } draw_projects(f, app, cols.projects); draw_worktrees(f, app, cols.worktrees); draw_sessions(f, app, cols.sessions); @@ -62,6 +73,36 @@ pub(super) fn draw(f: &mut Frame, app: &mut App, body: Rect) { } }; draw_focus_tint(f.buffer_mut(), tinted, app.theme); + draw_grips(f.buffer_mut(), app, &cols, body); +} + +/// The grip on each column's rule: a short heavy stretch down its middle, +/// the one visible sign that the BORDER can be dragged — muted at rest, +/// the accent while the pointer rests on it or while it is being dragged, +/// as the LAUNCHER VIEW's pane grip is. +fn draw_grips( + buf: &mut ratatui::buffer::Buffer, + app: &App, + cols: &crate::panels::Columns, + body: Rect, +) { + let th = app.theme; + if body.height < GRIP_H + 2 { + return; // no room for the grip and rule either side of it + } + let top = body.y + (body.height - GRIP_H) / 2; + for i in 0..3 { + let x = cols.border(i).saturating_sub(1); + let active = + app.panels_drag.map(|(at, _)| at) == Some(i) || app.hover_panels_border == Some(i); + let fg = if active { th.accent } else { th.muted }; + for y in top..top + GRIP_H { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol("┃"); + cell.set_style(Style::default().fg(fg)); + } + } + } } /// A column's frame: its rule down the right, a blank row, the title with @@ -218,6 +259,7 @@ pub(super) fn help_sections() -> (&'static [HelpSection], &'static [HelpSection] (Lit("⌥click"), "open URL / file under cursor"), (Lit("⇧drag"), "select via your terminal"), (Lit("right-click"), "row menu: run, restart"), + (Lit("drag a border"), "resize the column"), (Lit("click outside"), "dismiss any modal (= Esc)"), ], ), diff --git a/docs/keys.md b/docs/keys.md index b2fb5025..732a723d 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -140,6 +140,7 @@ PROJECTS column lists every project and stands in for the PROJECT TABS. | `x`, `1`–`9`, `+`, `` ` ``, `` ^` `` | grid-only — the PROJECT TABS, the pane's terminal strip and its fold have nothing to act on here, and the footer says so | | click | a row selects it and its column takes the keys, a second click is `Enter` on it; a click on a group header (`OPEN PRS`, `ISSUES`, `ARCHIVED`) folds it; a click on the pane steps into it | | wheel | over a column longer than the screen: scroll it under the cursor, which stays put; the next key that moves the cursor brings it back on screen. `?` lists the panels' keys while they are up | +| drag a border | a column's right border — its rule, with the grip down its middle — resizes that column, and the pane takes up the difference; a column stops at 10 columns wide and the pane at 20. The widths are remembered across restarts, and a window too narrow for them squeezes the columns without forgetting them. Grabbing a border selects nothing and moves no FOCUS | ## Chips and readouts diff --git a/docs/sessions.md b/docs/sessions.md index 1667a780..fc0cb554 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -507,6 +507,8 @@ above works from it, on the selection the columns show. into it. - `n` in SESSIONS is the NEW SESSION PICKER for the selected checkout, `p` the QUICK PROMPT, `Space` the FOLLOW-UP MODAL. The breadcrumb in the footer reads `project ▸ worktree ▸ session`. +- Dragging a column's right border resizes it, the pane taking up the difference; the widths are + remembered across restarts. The cards' own extras — the last prompt and line counts on a card, the header's PR and issue counts, the pane's terminal strip — are the grid's and have no column here. From 5cd7f74ee45895b2582555cad9c1fea1c934fbad Mon Sep 17 00:00:00 2001 From: David Budac Date: Sun, 4 Oct 2026 15:48:03 +0200 Subject: [PATCH 3/5] The panels look and work as they did before the grid: old row shapes, collapsible columns, the old keys, help and colours, and the inline follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rows are the old shapes again: 3-row PROJECT buttons, 2-row pills with a full-height rail for checkouts, sessions, PRs and issues. A project row carries its `3 prs · 2 issues` counts and a renamed project's `└ folder`; a merged checkout goes purple with its one-shot sweep; `▶ running`, ` N new` comments, `100+` / `9/12` group counts, the empty WORKTREES hint and the footer's `+N files` are back. - Columns collapse to `▶` rails: a `◀` on each header, ⇧P / ⇧B / ⇧S and ^B / ⌘B / ⇧Z, kept in `hide_projects` / `hide_worktrees` / `hide_sessions`, with remembered widths. SESSIONS folds to its rule under an OPEN PRS or ISSUES row. Saved widths that no longer fit shrink in proportion, each open column held at MIN_W and the pane at MIN_PANE_W. - PANEL_KEYS, panels only and ahead of the keymap: ⇧Tab / ^⇧H / ^⇧L walk, ^→ into the pane without its input, `z` full screen, `m` the row menu, ⇧C a Ghostty tab. The grid keeps its own bindings. - `?` is the old help; the footer ends `m: menu ?: help`; the focused column takes the old per-preset tint; the key echo stays hidden. - Space expands a session pill into the old inline FOLLOW-UP box; RECENT PROMPTS lines return under each pill (`recent_prompts`, `recent_prompts_count`). - Settings › Appearance ends on a PANELS LAYOUT group for these settings, dated so they don't show as NEW to grid users. - Panels start on PROJECTS; switching back to the grid moves FOCUS to SESSIONS so `d` / `r` never land on a column the grid lacks. - docs/keys.md, docs/sessions.md and docs/configuration.md follow. Co-Authored-By: Claude Opus 5.5 --- crates/nebula-tui/src/app.rs | 58 +- crates/nebula-tui/src/config.rs | 214 ++- crates/nebula-tui/src/event_loop.rs | 62 +- .../nebula-tui/src/event_loop/focus_walk.rs | 8 + crates/nebula-tui/src/event_loop/panels.rs | 1321 ++++++++++++++++- crates/nebula-tui/src/panels.rs | 964 +++++++++--- crates/nebula-tui/src/ui.rs | 60 +- crates/nebula-tui/src/ui/panels_view.rs | 1062 ++++++++++--- crates/nebula/tests/e2e_tui.rs | 18 +- docs/configuration.md | 14 +- docs/keys.md | 19 +- docs/sessions.md | 95 +- 12 files changed, 3349 insertions(+), 546 deletions(-) diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index b942b6fd..435bc4ea 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -80,6 +80,10 @@ pub enum HitTarget { /// (`panels::Columns::grab_zone`). Registered ahead of the rows, so a /// grab there never selects one. PanelsBorder(usize), + /// A PANELS column's fold button: the `◀` at the right end of its + /// title, which folds it to its RAIL, or the RAIL itself, which opens + /// it again (`event_loop::panels::click_fold`). + PanelsFold(Focus), TerminalPane, /// The session URL on the CLOUD SESSION PANEL; a click opens it in the /// browser. Registered ahead of the pane it sits on, so it wins. @@ -2336,10 +2340,12 @@ impl<'a> WorktreeRow<'a> { } /// What a click landed on, for the double-click window. Sessions are their -/// own reference; a checkout is its id. +/// own reference; a link has none — a PANELS pull request, issue or link +/// row — so its URL is the identity; a checkout is its id. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RowKey { Session(SessionRef), + Link(String), Worktree(WorktreeId), } @@ -3322,6 +3328,21 @@ pub struct App { /// kept from a wider window come back with it. Remembered across /// restarts. pub panels_widths: Option<[u16; 3]>, + /// The PANELS' columns folded by hand to their RAILS — PROJECTS, + /// WORKTREES, SESSIONS — by their title's `◀`, their `⇧P` / `⇧B` / + /// `⇧S`, or `^B` for all three (`event_loop::panels`); mirrors + /// CONFIG.JSON's `hide_projects`, `hide_worktrees` and + /// `hide_sessions`, written as they change so a fold survives a + /// restart. A folded column keeps its `panels_widths` entry and opens + /// back up to it. What a frame folds is `panels::folds`, which adds + /// the SESSIONS fold beside a pull request or an issue. + pub panels_hidden: [bool; 3], + /// How many RECENT PROMPTS the PANELS' SESSIONS column hangs under + /// each live session's pill: the **Recent prompts** switch and + /// **Recent prompts shown** (Settings → Appearance, PANELS LAYOUT), + /// resolved through `Config::recent_prompts_shown` — 0 while off. The + /// GRID's cards carry the newest prompt whatever this says. + pub recent_prompts: usize, /// In-progress drag of a PANELS column's BORDER: the column, and /// `border column - grab column` at mouse-down, so the border tracks /// the pointer instead of jumping by one depending on which of the two @@ -3907,6 +3928,8 @@ impl App { panels: false, panels_scroll: Default::default(), panels_widths: None, + panels_hidden: [false; 3], + recent_prompts: 0, panels_drag: None, hover_panels_border: None, launcher_all_open: false, @@ -5353,7 +5376,7 @@ impl App { } /// The same for a checkout already in hand ([`App::sessions_in`]). - fn group_counts_in(&self, wt: &WorktreeId) -> (usize, usize) { + pub(crate) fn group_counts_in(&self, wt: &WorktreeId) -> (usize, usize) { let live = self .tree .agents @@ -5484,6 +5507,17 @@ impl App { worktree_rollup(&self.tree, worktree_id) } + /// The cached changed-file count when it belongs to the selected + /// worktree; `None` while unknown or the checkout is unreadable. The + /// PANELS' footer carries it after the breadcrumb. + pub fn selected_worktree_changes(&self) -> Option { + let wt = self.selected_worktree()?; + match &self.git_changes { + Some((id, count)) if *id == wt.id => *count, + _ => None, + } + } + /// Whether the checkout's row wears its pull request's merge instead of /// its sessions' status. The PR ROW keeps a merged pull request /// (`pull_requests` holds it, state and all), and a checkout whose @@ -5533,10 +5567,14 @@ impl App { /// The two places FOCUS can rest: the LAUNCHER VIEW's GRID of cards /// and the PANE under them. The three columns the other variants name - /// are drawn only by the PANELS ([`App::panels_active`]), where all - /// four are places to rest. + /// are drawn only by the PANELS ([`App::panels_active`]), where the + /// pane and every column not folded are places to rest + /// (`panels::focus_open`). pub fn focus_visible(&self, focus: Focus) -> bool { - matches!(focus, Focus::Sessions | Focus::Terminal) || self.panels_active() + if self.panels_active() { + return crate::panels::focus_open(self, focus); + } + matches!(focus, Focus::Sessions | Focus::Terminal) } fn focus_rank(focus: Focus) -> u8 { @@ -5597,16 +5635,6 @@ impl App { /// Test-only accessors: nothing in the app reads these any more. #[cfg(test)] impl App { - /// The cached changed-file count when it belongs to the selected - /// worktree; `None` while unknown or the checkout is unreadable. - pub fn selected_worktree_changes(&self) -> Option { - let wt = self.selected_worktree()?; - match &self.git_changes { - Some((id, count)) if *id == wt.id => *count, - _ => None, - } - } - /// (live, archived) agent counts for the selected worktree. pub fn session_group_counts(&self) -> (usize, usize) { let Some(wt) = self.selected_worktree() else { diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index 4bd67928..5bf59d8c 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -50,6 +50,13 @@ pub const WORKTREE_LAYOUTS: &[&str] = &["cards", "list"]; /// `crate::panels`). pub const LAYOUTS: &[&str] = &["grid", "panels"]; +/// The **Recent prompts shown** choices (Settings → Appearance, PANELS +/// LAYOUT), in the order the row cycles them; a hand edit off the list is +/// clamped to what the DAEMON keeps ([`Config::recent_prompts_shown`]). +pub const RECENT_PROMPT_COUNTS: &[&str] = &["1", "2", "3", "4", "5"]; +/// How many RECENT PROMPTS a session row lists until the count is changed. +pub const DEFAULT_RECENT_PROMPTS_COUNT: usize = 3; + /// The **Preset text** choices (Settings → Sessions), in the order the row /// cycles them: the [`PresetText`] sides by label. pub const PRESET_TEXTS: &[&str] = &[ @@ -405,6 +412,11 @@ pub enum SettingKind { Animations, BlackBackground, Layout, + HideProjects, + HideWorktrees, + HideSessions, + RecentPrompts, + RecentPromptsCount, HideCardMarks, HighlightCurrentCard, SessionPane, @@ -478,14 +490,21 @@ impl SettingKind { | SettingKind::SessionIdleTimeout | SettingKind::Theme | SettingKind::Animations => (2026, 8, 22), - // v0.16.0 - SettingKind::DoneSound => (2026, 8, 28), + // v0.16.0; the PANELS' rows came back with the layout, never + // new to anyone who had them + SettingKind::DoneSound + | SettingKind::HideProjects + | SettingKind::HideWorktrees + | SettingKind::HideSessions => (2026, 8, 28), // v0.19.0 – v0.21.0 SettingKind::CloseFinderOnOpen | SettingKind::QuickPromptKind | SettingKind::QuickPromptFocus => (2026, 8, 29), - // v0.23.0 / v0.24.0 + // v0.23.0 / v0.24.0; RECENT PROMPTS came back with the + // PANELS, never new to anyone who had them SettingKind::FeedbackSound + | SettingKind::RecentPrompts + | SettingKind::RecentPromptsCount | SettingKind::WorktreeBaseBranch | SettingKind::PrewarmAgents | SettingKind::PrewarmSessions => (2026, 9, 9), @@ -709,6 +728,38 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ hint: "Show or hide draft pull requests in / search; checkouts always stay", group: "", }, + // The PANELS' own rows, under their own header: the GRID has + // no columns to collapse, and never reads them. + SettingSpec { + kind: SettingKind::HideProjects, + label: "Projects panel", + hint: "Collapse or expand the Projects panel (Shift+P toggles)", + group: "PANELS LAYOUT", + }, + SettingSpec { + kind: SettingKind::HideWorktrees, + label: "Worktrees panel", + hint: "Collapse or expand the Worktrees panel (Shift+B toggles)", + group: "PANELS LAYOUT", + }, + SettingSpec { + kind: SettingKind::HideSessions, + label: "Sessions panel", + hint: "Collapse or expand the Sessions panel (Shift+S toggles)", + group: "PANELS LAYOUT", + }, + SettingSpec { + kind: SettingKind::RecentPrompts, + label: "Recent prompts", + hint: "List a session's last prompts under its row, newest at the bottom, each with how long ago", + group: "PANELS LAYOUT", + }, + SettingSpec { + kind: SettingKind::RecentPromptsCount, + label: "Recent prompts shown", + hint: "How many of a session's recent prompts the Sessions panel lists", + group: "PANELS LAYOUT", + }, ]), }, // Generated from the harness registry: the static quick-prompt head @@ -1162,17 +1213,20 @@ pub struct Config { /// edits it. Still loaded and written back as stored, so an older /// build sharing the file keeps the bar its user chose. pub show_workspaces: bool, - /// RETIRED with the three-panel layout. Through 0.37 the **Projects panel** - /// SETTING (Settings → Appearance) collapsed that panel to a rail; the - /// GRID has no panels, so no tab shows the row and nothing reads it. - /// Still loaded and written back as stored, so an older build sharing - /// the file keeps the layout its user chose. + /// The PANELS' PROJECTS column folded to its RAIL (Settings → + /// Appearance → **Projects panel**, `⇧P` or the `◀` on its title + /// beside the columns), the pane taking its width. The GRID has no + /// columns and never reads it. False by default, so configs from + /// before the key open all three columns. pub hide_projects: bool, - /// RETIRED with the three-panel layout, as `hide_projects` is: the - /// **Worktrees panel** SETTING through 0.37. + /// The WORKTREES column the same way (**Worktrees panel**, `⇧B`), + /// independently of the other two. pub hide_worktrees: bool, - /// RETIRED with the three-panel layout, as `hide_projects` is: the - /// **Sessions panel** SETTING through 0.37. + /// The SESSIONS column the same way (**Sessions panel**, `⇧S`), + /// independently of the other two. Whatever this says, the column + /// folds to a bare rule while the WORKTREES cursor is on a pull + /// request or an issue, which has no sessions — a fold never written + /// here. pub hide_sessions: bool, /// RETIRED with the root always listed. Through 0.27 one switch for /// every project (Settings → Experimental), then through 0.35 the @@ -1186,15 +1240,18 @@ pub struct Config { /// stored, so an older build sharing the file keeps the choice its /// user made. pub hide_root_worktree: bool, - /// RETIRED with every card carrying its session's last prompt. Through - /// 0.37 the **Recent prompts** SETTING (Settings → Experimental) listed a - /// session's last prompts under its row; the card shows the newest one - /// whatever this says, so no tab shows the row and nothing reads it. - /// Still loaded and written back as stored, so an older build sharing - /// the file keeps the rows its user chose. + /// The PANELS' RECENT PROMPTS (Settings → Appearance → **Recent + /// prompts**, under PANELS LAYOUT): the last few things typed into each + /// session, as the daemon captured them off the `UserPromptSubmit` + /// hook, listed under its row in the SESSIONS column, newest at the + /// bottom, each with an ago label. Off by default: the rows are three + /// lines taller with it on. The GRID's cards carry the newest prompt + /// whatever this says, and never read it. pub recent_prompts: bool, - /// RETIRED with `recent_prompts`: how many prompts that build listed - /// (`1` to `5` in its overlay). Loaded and written back as stored. + /// How many of those prompts to list while `recent_prompts` is on. + /// The overlay cycles [`RECENT_PROMPT_COUNTS`]; a hand edit is clamped + /// to what the daemon keeps. Read through + /// [`Config::recent_prompts_shown`]. pub recent_prompts_count: usize, /// RETIRED with the KEY COMBO DISPLAY always on. Through 0.37 the /// **Key combo display** SETTING (Settings → Experimental) switched @@ -1461,7 +1518,7 @@ impl Default for Config { hide_sessions: false, hide_root_worktree: false, recent_prompts: false, - recent_prompts_count: 3, + recent_prompts_count: DEFAULT_RECENT_PROMPTS_COUNT, projects: BTreeMap::new(), show_key_combos: false, remember_harness: false, @@ -1630,6 +1687,18 @@ impl Config { self.layout.trim().eq_ignore_ascii_case("panels") } + /// How many RECENT PROMPTS the PANELS' SESSIONS column lists under a + /// session: zero while the feature is off, else the count clamped to + /// what the daemon keeps (a hand-edited `0` or `50` reads as `1` or the + /// cap, never as nothing while the switch says on). + pub fn recent_prompts_shown(&self) -> usize { + if !self.recent_prompts { + return 0; + } + self.recent_prompts_count + .clamp(1, nebula_core::RECENT_PROMPTS_KEPT) + } + /// The editor the file overlays launch: `NEBULA_EDITOR` when set, /// otherwise the `editor` setting, otherwise vim. pub fn editor_command(&self) -> String { @@ -2262,6 +2331,14 @@ impl Config { SettingKind::SessionPane => self.pane_side().as_str().into(), SettingKind::WorktreeLayout => WORKTREE_LAYOUTS[usize::from(self.list_layout())].into(), SettingKind::Layout => LAYOUTS[usize::from(self.panels_layout())].into(), + SettingKind::HideProjects => shown_hidden(self.hide_projects).into(), + SettingKind::HideWorktrees => shown_hidden(self.hide_worktrees).into(), + SettingKind::HideSessions => shown_hidden(self.hide_sessions).into(), + SettingKind::RecentPrompts => on_off(self.recent_prompts).into(), + SettingKind::RecentPromptsCount => self + .recent_prompts_count + .clamp(1, nebula_core::RECENT_PROMPTS_KEPT) + .to_string(), SettingKind::ExpandAllWorktrees => on_off(self.expand_all_worktrees).into(), SettingKind::CardIssueNumber => on_off(self.card_issue_number).into(), SettingKind::HideDraftPrs => shown_hidden(self.hide_draft_prs).into(), @@ -2376,6 +2453,25 @@ impl Config { let now = LAYOUTS[usize::from(self.panels_layout())]; self.layout = cycle_choice(now, LAYOUTS, step).into(); } + SettingKind::HideProjects => { + self.hide_projects = !self.hide_projects; + } + SettingKind::HideWorktrees => { + self.hide_worktrees = !self.hide_worktrees; + } + SettingKind::HideSessions => { + self.hide_sessions = !self.hide_sessions; + } + SettingKind::RecentPrompts => { + self.recent_prompts = !self.recent_prompts; + } + SettingKind::RecentPromptsCount => { + // A hand-edited count off the list steps onto it. + let current = self.recent_prompts_count.to_string(); + self.recent_prompts_count = cycle_choice(¤t, RECENT_PROMPT_COUNTS, step) + .parse() + .unwrap_or(DEFAULT_RECENT_PROMPTS_COUNT); + } SettingKind::ExpandAllWorktrees => { self.expand_all_worktrees = !self.expand_all_worktrees; } @@ -3736,8 +3832,10 @@ mod tests { rows.iter().all(|r| r.label != "Card line counts"), "no row edits it" ); + // The PANELS LAYOUT rows close the tab under their own header. + let mut grid_rows = rows.iter().filter(|r| r.group.is_empty()); assert_eq!( - rows.last().map(|r| r.kind), + grid_rows.next_back().map(|r| r.kind), Some(SettingKind::HideDraftPrs), "Appearance ends on DRAFT PULL REQUESTS" ); @@ -3961,6 +4059,78 @@ mod tests { assert!(!odd.panels_layout(), "a word off the list"); } + /// RECENT PROMPTS: a PANELS LAYOUT switch on the Appearance tab that is + /// off by default and a count beside it, read together through + /// `recent_prompts_shown` — zero while off, the count while on, a hand + /// edit clamped to what the daemon keeps — and both persisted under + /// their own keys. + #[test] + fn recent_prompts_are_off_by_default_and_the_count_cycles_and_persists() { + let mut cfg = Config::default(); + assert!(!cfg.recent_prompts, "rows stay short until asked"); + assert_eq!(cfg.recent_prompts_count, DEFAULT_RECENT_PROMPTS_COUNT); + assert_eq!(cfg.recent_prompts_shown(), 0, "off means none drawn"); + assert_eq!(cfg.value_label(SettingKind::RecentPrompts), "off"); + assert_eq!(cfg.value_label(SettingKind::RecentPromptsCount), "3"); + + let (tab, row) = locate(SettingKind::RecentPrompts).unwrap(); + assert_eq!(SETTINGS_TABS[tab].title, "Appearance"); + let (count_tab, count_row) = locate(SettingKind::RecentPromptsCount).unwrap(); + assert_eq!(count_tab, tab); + assert_eq!(count_row, row + 1, "the count sits under its switch"); + + cfg.cycle(tab, row, 0); + assert!(cfg.recent_prompts); + assert_eq!(cfg.recent_prompts_shown(), 3); + + // The count walks the list both ways and wraps. + cfg.cycle(count_tab, count_row, 1); + assert_eq!(cfg.recent_prompts_count, 4); + cfg.cycle(count_tab, count_row, 1); + cfg.cycle(count_tab, count_row, 1); + assert_eq!(cfg.recent_prompts_count, 1, "wraps past 5"); + cfg.cycle(count_tab, count_row, -1); + assert_eq!(cfg.recent_prompts_count, 5); + assert_eq!(cfg.value_label(SettingKind::RecentPromptsCount), "5"); + let most: usize = RECENT_PROMPT_COUNTS.last().unwrap().parse().unwrap(); + assert!( + most <= nebula_core::RECENT_PROMPTS_KEPT, + "the overlay never asks for more than the daemon keeps" + ); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + cfg.save_to(&path).unwrap(); + let loaded = load_from(&path); + assert!(loaded.recent_prompts); + assert_eq!(loaded.recent_prompts_count, 5); + assert_eq!(loaded.recent_prompts_shown(), 5); + + // A hand edit past the list is clamped, not refused; a count that + // is off the list steps back onto it when cycled. + let mut cfg: Config = + serde_json::from_str(r#"{"recent_prompts": true, "recent_prompts_count": 50}"#) + .unwrap(); + assert_eq!(cfg.recent_prompts_shown(), nebula_core::RECENT_PROMPTS_KEPT); + assert_eq!( + cfg.value_label(SettingKind::RecentPromptsCount), + nebula_core::RECENT_PROMPTS_KEPT.to_string() + ); + cfg.cycle(count_tab, count_row, 1); + assert_eq!( + cfg.recent_prompts_count, 2, + "off-list steps from the first choice" + ); + let cfg: Config = + serde_json::from_str(r#"{"recent_prompts": true, "recent_prompts_count": 0}"#).unwrap(); + assert_eq!(cfg.recent_prompts_shown(), 1); + + // A config predating the keys reads as off, with the default count. + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert!(!cfg.recent_prompts); + assert_eq!(cfg.recent_prompts_count, DEFAULT_RECENT_PROMPTS_COUNT); + } + /// The QUICK PROMPT's focus toggle: off unless the user turns it on, /// and persisted under its own key (a missed `obj.insert` would let the /// row toggle on screen and read back off on the next launch). diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 470f2630..b6a31bf4 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -273,6 +273,14 @@ async fn main_loop( let cfg = crate::config::Config::load(); apply_config(&mut app, &cfg); app.keymap = cfg.keymap(); + // The PANELS open with the keys on PROJECTS, the column at the top of + // the tree — where they always opened — rather than on the GRID's + // cards, which beside the columns is an empty SESSIONS column. + if app.panels { + app.focus = Focus::Projects; + // PROJECTS folded to its RAIL: the first open column, or the pane. + crate::panels::settle_focus(&mut app); + } // Every pull request the last run knew about, painted before the // daemon's snapshot even lands; the lookups below refresh them all in // the background (`pr_cache`). @@ -2973,8 +2981,13 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { // down; in a full-screen session the hatches and the pane fold's // `^`` bring it back down too, rather than straight out to the // grid — the keys stay in the session, now in its pane. + // A PANELS session full-screened by `z` or `^F` comes back down + // to its pane on `^F` alone: a hatch leaves it for the columns, as + // it always did there. let zooms = toggles_full_screen(app, &chord) - || (app.collapsed && (is_hatch || folds_launcher_pane(app, &chord))); + || (app.collapsed + && !app.panels_active() + && (is_hatch || folds_launcher_pane(app, &chord))); if app.launcher_active() && zooms { let did = launcher::toggle_full_screen(app, out); crate::key_combo::note(app, &[chord], Some(did)); @@ -3106,6 +3119,15 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { // in between — bound or not — breaks it, so the arm is taken here and // only the edge arms below put one back. let armed = app.edge_tap.take(); + // The PANELS' fold keys and their own keys come ahead of the keymap — + // `⇧P` folds PROJECTS there, whatever the GRID binds it to — and only + // beside the columns (`panels::fold_key`, `panels::panel_key`). + if app.panels_active() + && !app.collapsed + && (panels::fold_key(app, &chord) || panels::panel_key(app, &chord, out)) + { + return; + } let action = app.keymap.lookup(crate::keymap::Scope::Global, &chord); // The KEY COMBO DISPLAY: the key and the label of what it fired — an // unbound key shows bare, so a watcher sees it did nothing. Noted @@ -5326,6 +5348,13 @@ fn context_menu_items(app: &App, focus: Focus) -> Option> { None => app.selected_worktree().map(|w| worktree_menu_items(app, w)), }, }, + // A PANELS session row: the session's verbs alone, the checkout's + // being its WORKTREES row's there. + Focus::Sessions if app.panels_active() => Some(match app.selected_session_row()? { + SessionRow::Agent(a) => menu_items_for_session(&a), + SessionRow::Terminal(t) => menu_items_for_terminal(&t), + SessionRow::Link(l) => menu_items_for_link(&l), + }), // An EMPTY BAND on the grid: its checkout's own menu, the same // **Delete worktree** its `d` opens — its pull request's link row // under the cursor or not (#104). @@ -6465,6 +6494,28 @@ fn apply_config(app: &mut App, cfg: &crate::config::Config) { app.launcher_pane_at = cfg.pane_side(); app.launcher_list = cfg.list_layout(); app.panels = cfg.panels_layout(); + // The PANELS keep the FOCUS TINT they always had, the GRID its own. + if app.panels { + app.theme.focus_tint = crate::panels::focus_tint(&cfg.theme); + } + // The PANELS' columns folded as they were left (`panels::set_hidden`, + // which steps a FOCUS off a column folding under it). + let hidden = [cfg.hide_projects, cfg.hide_worktrees, cfg.hide_sessions]; + for (i, hidden) in hidden.into_iter().enumerate() { + panels::set_hidden(app, i, hidden); + } + // Their RECENT PROMPTS under each session pill; and a FOLLOW-UP + // COMPOSER open in a session pill folds with the columns, so the GRID + // never inherits a box it does not draw holding the keys. + app.recent_prompts = cfg.recent_prompts_shown(); + if !app.panels { + app.follow_up = None; + // The GRID has no PROJECTS or WORKTREES column to hold the keys: `d` + // and `r` there would act on the project. + if matches!(app.focus, Focus::Projects | Focus::Worktrees) { + app.focus = Focus::Sessions; + } + } app.launcher_all_open = cfg.expand_all_worktrees; set_hide_draft_prs(app, cfg.hide_draft_prs); } @@ -7240,7 +7291,11 @@ fn run_menu_action(app: &mut App, action: MenuAction, out: &mut Vec launcher::follow_up(app), + // Beside the PANELS the row's pill expands into its FOLLOW-UP + // COMPOSER, as Space does there; the grid's card opens its modal. + MenuAction::FollowUp if app.launcher_active() && !app.panels_active() => { + launcher::follow_up(app) + } MenuAction::FollowUp => activate::follow_up(app), MenuAction::EditLink(id) => open_prompt(app, PromptKind::EditLink { id }), MenuAction::DeleteLink(id) => { @@ -9679,6 +9734,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // A PANELS column's BORDER: a resize drag armed, and // nothing selected or focused (`panels::grab_border`). Some(HitTarget::PanelsBorder(i)) => panels::grab_border(app, i, mouse.column), + // A PANELS column's `◀` folds it to its RAIL, and the rail + // opens it again (`panels::click_fold`). + Some(HitTarget::PanelsFold(focus)) => panels::click_fold(app, focus), // The `❮` / `❯` beside a band's row: one card that way // along the band, the very step `h` / `l` take. Some(HitTarget::LauncherStripLeft(i)) => { diff --git a/crates/nebula-tui/src/event_loop/focus_walk.rs b/crates/nebula-tui/src/event_loop/focus_walk.rs index 1b3cb753..9c63c441 100644 --- a/crates/nebula-tui/src/event_loop/focus_walk.rs +++ b/crates/nebula-tui/src/event_loop/focus_walk.rs @@ -103,6 +103,14 @@ pub(super) fn land_click_focus(app: &mut App, column: u16, row: u16, out: &mut V ) => app.focus = Focus::Sessions, Some(HitTarget::PanelBg(focus)) => app.focus = focus, Some(HitTarget::PanelsRow(row)) => app.focus = row.focus(), + // The click was spent closing the modal, so a PANELS column's fold + // button only takes FOCUS while its column is open; folding it or + // opening it is the direct click's job. + Some(HitTarget::PanelsFold(focus)) => { + if app.focus_visible(focus) { + app.focus = focus; + } + } Some(HitTarget::TerminalPane | HitTarget::CloudSessionLink) => { enter_terminal_pane(app, out) } diff --git a/crates/nebula-tui/src/event_loop/panels.rs b/crates/nebula-tui/src/event_loop/panels.rs index 8a53ec10..1a79ff34 100644 --- a/crates/nebula-tui/src/event_loop/panels.rs +++ b/crates/nebula-tui/src/event_loop/panels.rs @@ -2,20 +2,26 @@ //! `ui::panels_view` its drawing). Most of what the panels answer to is //! the panel walk `event_loop::handle_key` has always kept under the GRID — //! `h`/`l` across the columns (`focus_walk`), `j`/`k` down them -//! (`move_selection`), Enter into the pane, and every verb that reads the +//! (`move_selection`), Enter into the pane, Space expanding a session's +//! pill into its FOLLOW-UP COMPOSER (`activate::follow_up`, whose box then +//! takes the keys, `follow_up_key`), and every verb that reads the //! selection — so this module only takes the keys the GRID owns and gives //! them their panel meaning, or a word saying they have none here -//! ([`handle_action`]), and translates a click on a row ([`click_row`]), a -//! drag of a column's BORDER ([`grab_border`], [`move_border`]) or a -//! notch of the wheel over a column ([`wheel`]). +//! ([`handle_action`]), folds a column to its RAIL and opens it again +//! ([`fold_key`], [`click_fold`]), answers the keys the columns always +//! had that the GRID has not ([`panel_key`]), and translates a click on a row +//! ([`click_row`]), a drag of a column's BORDER ([`grab_border`], +//! [`move_border`]) or a notch of the wheel over a column ([`wheel`]). use super::{ - activate, attach_selected, is_double_click, jump_attention, launcher, select_project_row, - select_session_row, select_worktree_row, toggle_issues, toggle_open_prs, zoom_pane, + activate, attach_selected, context_menu_items, is_double_click, jump_attention, + open_ghostty_tab, open_menu, select_project_row, select_session_row, select_worktree_row, + toggle_issues, toggle_open_prs, walk_focus_back, walk_focus_forward, zoom_pane, + KEYBOARD_MENU_ANCHOR, }; use crate::app::{App, Focus, HitTarget, RowKey}; -use crate::keymap::Action; -use crate::panels::Row; +use crate::keymap::{Action, KeyChord}; +use crate::panels::{Fold, PanelKey, Row}; use nebula_core::ClientRequest; use std::time::Duration; @@ -37,9 +43,6 @@ const NOTHING_TO_FULL_SCREEN: &str = "no session in the pane — j/k onto one, t /// other key falls through to its panel meaning, which reads the same /// selection the columns' cursors are. /// -/// * `Space` on a session: the FOLLOW-UP MODAL the grid's card opens -/// (`launcher::follow_up`). Off the SESSIONS column there is no session -/// under the cursor to prompt, and it does nothing. /// * `^F`: the session under the cursor full-screen ([`toggle_full_screen`]). /// * `]` / `[`: the attention walk, the meaning they had in the panels — /// the same ring `.` / `,` walk. @@ -56,11 +59,6 @@ pub(super) fn handle_action(app: &mut App, action: Action, out: &mut Vec { - if app.focus == Focus::Sessions { - launcher::follow_up(app); - } - } Action::ToggleFullScreen => toggle_full_screen(app, out), Action::NextProjectTab | Action::PrevProjectTab => { let step = if action == Action::NextProjectTab { @@ -82,12 +80,144 @@ pub(super) fn handle_action(app: &mut App, action: Action, out: &mut Vec bool { + if let Some(i) = crate::panels::FOLD_KEYS.iter().position(|k| k == chord) { + toggle(app, i); + } else if crate::panels::FOLD_ALL_KEYS.contains(chord) { + toggle_all(app); + } else { + return false; + } + true +} + +/// One of the PANELS' own keys while they are up — true when `chord` was +/// one and was taken here, ahead of the keymap, as [`fold_key`] takes its +/// own (`panels::PANEL_KEYS`): the backward walk (`⇧Tab` / `^⇧H`) and +/// the forward one's `^⇧L`, `^→` onto the next column or into the pane +/// without the input lock, `z` the pane FULL-SCREEN, `m` the cursor row's +/// CONTEXT MENU ([`open_row_menu`]) and `⇧C` a Ghostty tab. The GRID never +/// asks, so it keeps whatever the keymap binds the chords to. +pub(super) fn panel_key(app: &mut App, chord: &KeyChord, out: &mut Vec) -> bool { + let Some(&(_, key)) = crate::panels::PANEL_KEYS.iter().find(|(k, _)| k == chord) else { + return false; + }; + // The focused column reveals its cursor again, as every key that + // reaches `handle_action` makes it. + if app.focus != Focus::Terminal { + app.panels_scroll[crate::panels::scroll_slot(app.focus)].reveal_next(); + } + match key { + PanelKey::FocusPrev => walk_focus_back(app), + PanelKey::FocusNext => walk_focus_forward(app, out), + PanelKey::FocusTerminal => app.focus = app.next_visible_focus(app.focus), + PanelKey::Zoom => { + if app.term.is_some() { + zoom_pane(app, out); + } else { + app.flash = Some(ATTACH_FIRST.into()); + } + } + PanelKey::ContextMenu => open_row_menu(app), + PanelKey::OpenGhosttyTab => open_ghostty_tab(app), + } + app.dirty = true; + true +} + +/// What `z` says with nothing in the pane to full-screen. +const ATTACH_FIRST: &str = "attach a session first"; + +/// `m`: the CONTEXT MENU of the row under the focused column's cursor — +/// the one a right-click on that row opens (`context_menu_items`) — at +/// the fixed spot near the columns' top left a keyboard menu always hung +/// at (`KEYBOARD_MENU_ANCHOR`). A column with no row under its cursor has +/// no menu, and neither has the pane; the column's own verbs are its +/// empty background's right-click. +fn open_row_menu(app: &mut App) { + if let Some(items) = context_menu_items(app, app.focus) { + open_menu(app, items, KEYBOARD_MENU_ANCHOR); + } +} + +/// Column `i` folded to its RAIL, or opened back up to the width it was +/// dragged to: its `⇧` key, the `◀` on its title and the rail itself all +/// come here, and so the choice is written to CONFIG.JSON however it was +/// made ([`save_folds`]), surviving a restart. +fn toggle(app: &mut App, i: usize) { + set_hidden(app, i, !app.panels_hidden[i]); + save_folds(app); +} + +/// `^B`: every column folded to its RAIL, the pane taking the whole body +/// — FOCUS left on a column goes into the pane — or, with none open, +/// every one opened again, FOCUS left where it is. +fn toggle_all(app: &mut App) { + if crate::panels::folds(app).contains(&Fold::Open) { + for i in 0..3 { + set_hidden(app, i, true); + } + if !app.focus_visible(app.focus) { + app.focus = Focus::Terminal; + } + app.flash = Some("panels collapsed".into()); + } else { + for i in 0..3 { + set_hidden(app, i, false); + } + app.flash = Some("panels expanded".into()); + } + save_folds(app); +} + +/// Column `i` folded by hand, or opened: a FOCUS on a column folding +/// under it steps on to the next open one, the pane past the last. +pub(super) fn set_hidden(app: &mut App, i: usize, hidden: bool) { + app.panels_hidden[i] = hidden; + let focus = [Focus::Projects, Focus::Worktrees, Focus::Sessions][i]; + if hidden && app.focus == focus && app.panels_active() { + app.focus = app.next_visible_focus(focus); + } + app.dirty = true; +} + +/// CONFIG.JSON's `hide_projects`, `hide_worktrees` and `hide_sessions` +/// written from the columns' folds, so the next start opens them as they +/// were left. +fn save_folds(app: &mut App) { + let mut cfg = crate::config::Config::load(); + [cfg.hide_projects, cfg.hide_worktrees, cfg.hide_sessions] = app.panels_hidden; + if let Err(err) = cfg.save() { + app.flash = Some(format!("couldn't save settings: {err}")); + } +} + +/// A click on a column's fold button (`HitTarget::PanelsFold`): the `◀` on +/// an open column's title folds it — FOCUS taken first, so a cursor there +/// steps off it as the key would move it — and a RAIL opens again, FOCUS +/// left where it was. +pub(super) fn click_fold(app: &mut App, focus: Focus) { + let Some(i) = crate::panels::column_index(focus) else { + return; + }; + if app.focus_visible(focus) { + app.focus = focus; + } + toggle(app, i); +} + /// `^F` beside the columns: the session in the pane full-screen with the /// input lock on — from the SESSIONS column the row under the cursor, /// attached first, exactly as Enter on it would; from the pane, or the -/// columns above, whatever the pane is showing. `^F` (or `^q`) from the -/// full-screen session comes back down to the pane with the keys still in -/// it (`launcher::toggle_full_screen`, which the locked pane runs). +/// columns above, whatever the pane is showing. `^F` from the full-screen +/// session comes back down to the pane with the keys still in it +/// (`launcher::toggle_full_screen`, which the locked pane runs); `^q` +/// leaves it for the columns, as it leaves `z`'s. fn toggle_full_screen(app: &mut App, out: &mut Vec) { if app.focus == Focus::Sessions { let Some(row) = app.selected_session_row() else { @@ -131,8 +261,9 @@ pub(super) fn toggle_archived(app: &mut App, out: &mut Vec) { /// column taking FOCUS: the move the arrow keys make onto it /// (`select_project_row`, `select_worktree_row`, `select_session_row`), /// with the context it brings — the project's checkouts, the checkout's -/// session in the pane. False for a header, which has no cursor to move -/// and no menu of its own. +/// session in the pane. False for a header, a session's FOLLOW-UP CHEVRON +/// and its open composer, which have no cursor to move and no menu of +/// their own. pub(super) fn select_row(app: &mut App, row: Row, out: &mut Vec) -> bool { match row { Row::Project(i) => { @@ -145,8 +276,21 @@ pub(super) fn select_row(app: &mut App, row: Row, out: &mut Vec) select_worktree_row(app, i, out); } } - Row::Session(i) => select_session_row(app, i, Duration::ZERO, out), - Row::OpenPrsHeader | Row::IssuesHeader | Row::ArchivedHeader => return false, + Row::Session(i) => { + // A pointer moved onto another pill is the one way the cursor + // leaves an open FOLLOW-UP COMPOSER while it holds the + // keyboard, so it folds: a box on one pill with the cursor on + // another would leave `j` typing a letter instead of moving. + if app.follow_up_row().is_some_and(|open| open != i) { + app.follow_up = None; + } + select_session_row(app, i, Duration::ZERO, out) + } + Row::OpenPrsHeader + | Row::IssuesHeader + | Row::ArchivedHeader + | Row::FollowUp(_) + | Row::FollowUpBox => return false, } app.focus = row.focus(); app.dirty = true; @@ -155,32 +299,66 @@ pub(super) fn select_row(app: &mut App, row: Row, out: &mut Vec) /// A left click on the PANELS: a row takes the cursor ([`select_row`]), /// and a second click on the same one is Enter on it — a checkout hands -/// FOCUS to its sessions, a session is attached and takes the keys. A -/// group header folds its group, or opens it. +/// FOCUS to its sessions, a pull request or an issue opens in the +/// browser, a session is attached and takes the keys, a link opens; an +/// archived session says why neither happens. Landing on any other row +/// breaks the chain, so a click away and back never reads as a +/// double-click. A group header folds its group, or opens it; a session's +/// FOLLOW-UP CHEVRON puts the cursor on its row and expands the pill or +/// folds it — `activate::follow_up`, exactly what Space on it does — and +/// a click inside the open composer only gives SESSIONS FOCUS. pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { match row { Row::OpenPrsHeader => toggle_open_prs(app, out), Row::IssuesHeader => toggle_issues(app, out), Row::ArchivedHeader => toggle_archived(app, out), + Row::FollowUp(i) => { + select_row(app, Row::Session(i), out); + activate::follow_up(app); + } + Row::FollowUpBox => { + app.focus = Focus::Sessions; + app.dirty = true; + } Row::Project(_) => { select_row(app, row, out); } Row::Worktree(_) => { select_row(app, row, out); - let Some(id) = app.selected_worktree().map(|w| w.id.clone()) else { - return; + let key = match app.selected_worktree_pr() { + Some(pr) => Some(RowKey::Link(pr.url.clone())), + None => match app.selected_worktree_issue() { + Some(issue) => Some(RowKey::Link(issue.url.clone())), + None => app + .selected_worktree() + .map(|w| RowKey::Worktree(w.id.clone())), + }, }; - if is_double_click(&mut app.last_session_click, RowKey::Worktree(id)) { - activate::worktrees_row(app, out); + match key { + Some(key) => { + if is_double_click(&mut app.last_session_click, key) { + activate::worktrees_row(app, out); + } + } + None => app.last_session_click = None, } } Row::Session(_) => { select_row(app, row, out); - let Some(sref) = app.selected_session_row().and_then(|r| r.sref()) else { - return; - }; - if is_double_click(&mut app.last_session_click, RowKey::Session(sref)) { - attach_selected(app, out); + match app.selected_session_row() { + Some(row) if row.is_archived_agent() => { + app.flash = Some(super::AGENT_ARCHIVED.into()); + } + Some(row) => { + let key = match row.sref() { + Some(sref) => RowKey::Session(sref), + None => RowKey::Link(row.name().to_string()), + }; + if is_double_click(&mut app.last_session_click, key) { + attach_selected(app, out); + } + } + None => {} } } } @@ -193,7 +371,7 @@ pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { /// by one depending on which of its two grab cells was caught; the border /// is measured by the arithmetic the draw laid it out with. pub(super) fn grab_border(app: &mut App, column: usize, x: u16) { - let border = crate::panels::columns(app.body_area, app.panels_widths).border(column); + let border = crate::panels::layout(app, app.body_area).border(column); app.panels_drag = Some((column, i32::from(border) - i32::from(x))); } @@ -207,18 +385,21 @@ pub(super) fn move_border(app: &mut App, x: u16) { return; }; let to = i32::from(x) + grab; - if let Some(widths) = crate::panels::drag_border(app.body_area, app.panels_widths, column, to) { + let folds = crate::panels::folds(app); + if let Some(widths) = + crate::panels::drag_border(app.body_area, app.panels_widths, folds, column, to) + { app.panels_widths = Some(widths); } app.dirty = true; } -/// Lines a notch of the wheel scrolls a PANELS column: a third of the +/// Rows a notch of the wheel scrolls a PANELS column: a third of the /// GRID's card, as `launcher::GRID_WHEEL_ROWS`. -const WHEEL_LINES: isize = 3; +const WHEEL_ROWS: isize = 3; /// A notch of the wheel over a PANELS column — a row, a group header or -/// the air under them — scrolls that column a few lines under a cursor +/// the air under them — scrolls that column a few rows under a cursor /// that stays put (`ColumnScroll::wheel`): the pane keeps reading the /// session it was on, so a trackpad never swaps it out from under you, /// and nothing here moves a cursor, FOCUS or sends a request. The scroll @@ -232,7 +413,7 @@ pub(super) fn wheel(app: &mut App, over: Option<&HitTarget>, up: bool) -> bool { Some(HitTarget::PanelBg(focus)) => *focus, _ => return false, }; - let delta = if up { -WHEEL_LINES } else { WHEEL_LINES }; + let delta = if up { -WHEEL_ROWS } else { WHEEL_ROWS }; if app.panels_scroll[crate::panels::scroll_slot(focus)].wheel(delta) { app.dirty = true; } @@ -318,6 +499,13 @@ mod tests { (terminal, text) } + /// [`draw`] on a window tall enough for the whole `?` overlay. + fn draw_tall(app: &mut App) -> String { + let mut terminal = Terminal::new(TestBackend::new(140, 44)).unwrap(); + terminal.draw(|f| crate::ui::draw(f, app)).unwrap(); + buffer_text(&terminal) + } + fn key(app: &mut App, c: char, out: &mut Vec) { press(app, KeyCode::Char(c), KeyModifiers::NONE, out); } @@ -336,9 +524,13 @@ mod tests { for word in ["PROJECTS", "WORKTREES", "SESSIONS", "TERMINAL"] { assert!(text.contains(word), "{word}: {text}"); } - for row in ["demo", "web", "main ⌂ root", "feat", "RECENT"] { + for row in ["demo", "web", "main ⌂", "feat", "agent-1"] { assert!(text.contains(row), "{row}: {text}"); } + assert!( + !text.contains("RECENT"), + "the live list has no header: {text}" + ); assert!( !app.hits .iter() @@ -374,6 +566,7 @@ mod tests { assert_eq!(before, (app.sel_project, app.sel_worktree, app.sel_session)); apply_config(&mut app, &crate::config::Config::default()); assert!(app.launcher_grid()); + assert_eq!(app.focus, Focus::Sessions, "no column to hold the keys"); let (_, text) = draw(&mut app); assert_eq!(before, (app.sel_project, app.sel_worktree, app.sel_session)); assert!(!text.contains("WORKTREES"), "the grid again: {text}"); @@ -437,9 +630,10 @@ mod tests { ); } - /// The grid's own keys never strand the panels: Space is the - /// FOLLOW-UP MODAL on a session, and the PROJECT TAB keys, the pane - /// fold and its strip only say they have nothing to act on here. + /// The grid's own keys never strand the panels: Space expands the + /// session's pill into its FOLLOW-UP COMPOSER — no modal over the + /// screen — and the PROJECT TAB keys, the pane fold and its strip only + /// say they have nothing to act on here. #[test] fn the_grids_own_keys_are_harmless_beside_the_columns() { let mut app = panels_app(); @@ -454,14 +648,8 @@ mod tests { assert_eq!(app.tree.projects.len(), projects); assert!(app.panels_active(), "no tab was closed"); key(&mut app, ' ', &mut out); - assert!( - matches!( - &app.overlay, - Some(Overlay::Prompt(p)) if matches!(p.kind, PromptKind::FollowUp { .. }) - ), - "{:?}", - app.overlay - ); + assert!(app.overlay.is_none(), "no modal: {:?}", app.overlay); + assert!(app.follow_up_live(), "the pill expanded"); } /// `^F` on a session row full-screens it with the keys in it; `^F` @@ -535,7 +723,8 @@ mod tests { } /// `⇧A` opens the ARCHIVED group under the live rows and folds it - /// again, in place: the cursor stays in the checkout it was in. + /// again, in place, to the one line that counts it: the cursor stays + /// in the checkout it was in. #[test] fn shift_a_folds_the_archived_group_in_place() { let mut app = panels_app(); @@ -557,13 +746,13 @@ mod tests { ); let (_, text) = draw(&mut app); assert!( - text.contains("▸ ARCHIVED · 1") && !text.contains("old-run"), + text.contains("… 1 archived") && !text.contains("old-run"), "{text}" ); press(&mut app, KeyCode::Char('A'), KeyModifiers::SHIFT, &mut out); let (_, text) = draw(&mut app); assert!( - text.contains("▾ ARCHIVED · 1") && text.contains("old-run"), + text.contains("ARCHIVED · 1") && text.contains("old-run"), "{text}" ); assert_eq!(app.selected_worktree().map(|w| w.id.clone()), worktree); @@ -640,7 +829,7 @@ mod tests { MouseEventKind::ScrollDown, &mut out, ); - assert_eq!(app.panels_scroll[2].top, 3, "three lines a notch"); + assert_eq!(app.panels_scroll[2].top, 3, "three rows a notch"); assert_eq!( cursors, (app.sel_project, app.sel_worktree, app.sel_session) @@ -679,8 +868,8 @@ mod tests { assert!(out.is_empty()); } - /// The scroll holds at the column's first line and at the one that - /// puts the last line on the bottom row. + /// The scroll holds at the column's first row and at the one that + /// puts the last row on the bottom one. #[test] fn the_wheel_holds_at_both_ends() { let mut app = panels_app(); @@ -907,6 +1096,220 @@ mod tests { ); } + /// The rect `target` registered, panicking when it is not on screen. + fn hit_rect(app: &App, target: HitTarget) -> ratatui::layout::Rect { + app.hits + .iter() + .find(|(_, h)| *h == target) + .map(|(r, _)| *r) + .unwrap_or_else(|| panic!("{target:?} is not on screen")) + } + + /// PROJECTS are 3-row buttons and the checkouts 2-row PILLS on a + /// 2-row stride: a click anywhere on a button lands on its project, + /// and the cursor's pill wears its half-block pads. + #[test] + fn projects_are_buttons_and_checkouts_are_pills() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Worktrees; + let (terminal, _) = draw(&mut app); + let first = hit_rect(&app, HitTarget::PanelsRow(Row::Project(0))); + let second = hit_rect(&app, HitTarget::PanelsRow(Row::Project(1))); + assert_eq!((first.height, second.y), (3, first.y + 3)); + let root = hit_rect(&app, HitTarget::PanelsRow(Row::Worktree(0))); + let feat = hit_rect(&app, HitTarget::PanelsRow(Row::Worktree(1))); + assert_eq!(feat.y, root.y + 3, "a quiet row under the root"); + let buf = terminal.backend().buffer(); + assert_eq!(buf[(root.x + 3, root.y)].symbol(), "▄", "top pad"); + assert_eq!(buf[(root.x + 3, root.y + 2)].symbol(), "▀", "bottom pad"); + assert_eq!(buf[(root.x, root.y + 1)].symbol(), "█", "the rail"); + mouse_at( + &mut app, + MouseEventKind::Down(MouseButton::Left), + (second.x + 2, second.y + 2), + &mut out, + ); + assert_eq!((app.focus, app.sel_project), (Focus::Projects, 1)); + } + + /// A project button counts its open pull requests and issues after + /// the ago label, and a renamed one names the folder it lives in on + /// the row under it. + #[test] + fn a_project_button_counts_prs_and_issues_and_names_its_folder() { + use super::super::tests::{seed_issues, seed_open_prs}; + let mut app = panels_app(); + app.panels_widths = Some([44, 22, 32]); + seed_open_prs(&mut app, &[(1, "a"), (2, "b"), (3, "c")]); + seed_issues(&mut app, &[(7, "x"), (8, "y")]); + let at = app.project_rows()[app.sel_project]; + app.tree.projects[at].name = "api".into(); + let (_, text) = draw(&mut app); + assert!(text.contains("3 prs · 2 issues"), "{text}"); + assert!(text.contains("└ demo"), "{text}"); + } + + /// The OPEN PRS header owns up to drafts kept out — `0/3`, kept on + /// screen when every pull request is one — and a list at the fetch cap + /// says `100+`, as the ISSUES header says `+`. + #[test] + fn the_group_headers_own_up_to_hidden_drafts_and_the_cap() { + use super::super::tests::{seed_issues, seed_open_prs}; + let mut app = panels_app(); + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "demo") + .unwrap(); + seed_open_prs(&mut app, &[(1, "a"), (2, "b"), (3, "c")]); + let id = app.selected_project().unwrap().id.clone(); + for pr in &mut app.open_prs.get_mut(&id).unwrap().list { + pr.is_draft = true; + } + app.hide_draft_prs = true; + let (_, text) = draw(&mut app); + assert!(text.contains("▾ OPEN PRS · 0/3"), "{text}"); + let many: Vec<(u64, String)> = (0..100).map(|n| (n, format!("t{n}"))).collect(); + let many: Vec<(u64, &str)> = many.iter().map(|(n, t)| (*n, t.as_str())).collect(); + seed_open_prs(&mut app, &many); + seed_issues(&mut app, &many); + app.open_prs_collapsed = true; + let (_, text) = draw(&mut app); + assert!(text.contains("OPEN PRS · 100+"), "{text}"); + assert!(text.contains("ISSUES · 100+"), "{text}"); + } + + /// A checkout whose pull request merged wears the purple — dot and + /// branch — and one with its RUN COMMAND up says `▶ running`, or the + /// bare `▶` where the word would cut the branch. + #[test] + fn a_merged_checkout_is_purple_and_a_running_one_says_so() { + let mut app = panels_app(); + app.panels_widths = Some([20, 40, 32]); + let feat = WorktreeId("w2".into()); + app.pull_requests.insert( + feat.clone(), + Some(crate::pull_request::PullRequest { + number: 5, + url: "https://github.com/o/r/pull/5".into(), + title: "feat".into(), + state: crate::pull_request::STATE_MERGED.into(), + is_draft: false, + health: Default::default(), + activity: Vec::new(), + }), + ); + app.tree.terminals.push(nebula_core::TerminalTab { + id: nebula_core::TerminalId("t1".into()), + worktree_id: WorktreeId("w1".into()), + name: "run".into(), + sort_order: 0, + alive: true, + run_command: Some("make dev".into()), + }); + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "demo") + .unwrap(); + let (terminal, text) = draw(&mut app); + assert!(text.contains("main ▶ running"), "{text}"); + let rect = hit_rect(&app, HitTarget::PanelsRow(Row::Worktree(1))); + let buf = terminal.backend().buffer(); + let row = rect.y + 1; + let x = (rect.x..rect.right()) + .find(|x| buf[(*x, row)].symbol() == "f") + .expect("the branch"); + assert_eq!(buf[(x, row)].fg, app.theme.merged, "purple branch"); + assert_eq!(buf[(x - 2, row)].fg, app.theme.merged, "purple dot"); + app.panels_widths = Some([20, 14, 32]); + let (_, text) = draw(&mut app); + assert!( + text.contains("main ▶") && !text.contains("▶ running"), + "{text}" + ); + } + + /// The checkout's pull request counts the comments since it was last + /// opened from nebula, in place of its state word. + #[test] + fn the_pull_request_row_counts_unread_comments() { + let mut app = panels_app(); + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "demo") + .unwrap(); + app.sel_worktree = 0; + let url = "https://github.com/o/r/pull/9".to_string(); + app.pull_requests.insert( + WorktreeId("w1".into()), + Some(crate::pull_request::PullRequest { + number: 9, + url: url.clone(), + title: "fix".into(), + state: crate::pull_request::STATE_OPEN.into(), + is_draft: false, + health: Default::default(), + activity: vec!["2026-09-01T00:00:00Z".into(), "2026-09-02T00:00:00Z".into()], + }), + ); + let (_, text) = draw(&mut app); + assert!(text.contains("#9 fix 2 new"), "{text}"); + app.pr_seen.insert(url, "2026-09-01T00:00:00Z".into()); + let (_, text) = draw(&mut app); + assert!(text.contains("#9 fix 1 new"), "{text}"); + } + + /// A project with no checkout, pull request or issue says which key + /// starts a worktree. + #[test] + fn an_empty_project_says_how_to_start_a_worktree() { + let mut app = panels_app(); + app.panels_widths = Some([20, 26, 32]); + hse( + &mut app, + ServerEvent::EntityUpserted { + entity: Entity::Project(Project { + id: ProjectId("p3".into()), + name: "bare".into(), + repo_path: "/tmp/bare".into(), + sort_order: 2, + }), + }, + ); + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "bare") + .unwrap(); + let (_, text) = draw(&mut app); + assert!(text.contains("n starts a worktree"), "{text}"); + } + + /// The footer carries the checkout's changed files after the + /// breadcrumb beside the PANELS, and no KEY COMBO DISPLAY over it; the + /// GRID's footer is what it was. + #[test] + fn the_footer_counts_changed_files_and_echoes_no_keys() { + let mut app = panels_app(); + let w = app.selected_worktree().unwrap().id.clone(); + app.git_changes = Some((w, Some(3))); + let j = crate::keymap::KeyChord { + code: KeyCode::Char('j'), + mods: KeyModifiers::NONE, + }; + crate::key_combo::note(&mut app, &[j], Some("Move down")); + let (_, text) = draw(&mut app); + assert!(text.contains("+3 files"), "{text}"); + assert!(!text.contains("Move down"), "{text}"); + app.panels = false; + let (_, text) = draw(&mut app); + assert!(!text.contains("+3 files"), "{text}"); + assert!(text.contains("Move down"), "{text}"); + } + /// `?` beside the columns teaches the columns' keys, not the grid's /// cards and project tabs. #[test] @@ -921,22 +1324,24 @@ mod tests { "{:?}", app.overlay ); - let (_, text) = draw(&mut app); + let text = draw_tall(&mut app); for want in [ - "THE COLUMNS", - "walk the three columns", - "session: follow-up modal", - "fold the ARCHIVED group", - "project tabs: none here", - "resize the column", + "NAVIGATE & SEARCH", + "walk panels (fwd locks input)", + "PROJECTS", + "WORKTREES", + "SESSIONS", + "TERMINAL & MOUSE", + "GENERAL", + "TYPING IN A FIELD", + "context menu (right-click)", + "lock input (2nd: full-screen)", + "Enter / z", + "resize panels", ] { assert!(text.contains(want), "{want}: {text}"); } - for gone in [ - "NAVIGATE & SEARCH", - "walk the cards", - "fold / unfold the pane", - ] { + for gone in ["walk the cards", "fold / unfold the pane", "workspace"] { assert!(!text.contains(gone), "{gone}: {text}"); } } @@ -962,6 +1367,780 @@ mod tests { ] { assert!(text.contains(want), "{want}: {text}"); } - assert!(!text.contains("THE COLUMNS"), "{text}"); + assert!(!text.contains("walk panels"), "{text}"); + } + + /// A chord as the terminal sends it with modifiers held. + fn chord(app: &mut App, code: KeyCode, mods: KeyModifiers, out: &mut Vec) { + press(app, code, mods, out); + } + + /// `⇧Tab` and `^⇧H` walk FOCUS back a column, `^⇧L` forward as Tab + /// does, and `^→` onto the next column and on into the pane without + /// taking the input lock — the columns' walk as it always was. + #[test] + fn shift_tab_and_the_ctrl_chords_walk_the_columns() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + draw(&mut app); + chord(&mut app, KeyCode::BackTab, KeyModifiers::SHIFT, &mut out); + assert_eq!(app.focus, Focus::Worktrees); + let ctrl_shift = KeyModifiers::CONTROL | KeyModifiers::SHIFT; + chord(&mut app, KeyCode::Char('H'), ctrl_shift, &mut out); + assert_eq!(app.focus, Focus::Projects); + chord(&mut app, KeyCode::BackTab, KeyModifiers::SHIFT, &mut out); + assert_eq!(app.focus, Focus::Projects, "the walk stops at the first"); + chord(&mut app, KeyCode::Char('L'), ctrl_shift, &mut out); + assert_eq!(app.focus, Focus::Worktrees); + for want in [Focus::Sessions, Focus::Terminal, Focus::Terminal] { + chord(&mut app, KeyCode::Right, KeyModifiers::CONTROL, &mut out); + assert_eq!(app.focus, want); + } + assert!(!app.term_locked, "^→ reads the pane without typing"); + } + + /// `z` full-screens the pane with the keys in it, and the hatch out + /// of it (`^q`) goes straight back to the columns, as it always did; + /// with nothing in the pane `z` says so. + #[test] + fn z_full_screens_the_pane_and_the_hatch_leaves_for_the_columns() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + draw(&mut app); + key(&mut app, 'z', &mut out); + assert_eq!(app.flash.as_deref(), Some(super::ATTACH_FIRST)); + assert!(!app.collapsed); + let sref = app.selected_session_row().and_then(|r| r.sref()).unwrap(); + app.term = Some(AttachedTerm::new(sref, 40, 10)); + key(&mut app, 'z', &mut out); + assert!(app.collapsed && app.term_locked, "full screen, typing"); + assert_eq!(app.focus, Focus::Terminal); + draw(&mut app); + chord( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!(!app.collapsed && !app.term_locked); + assert_eq!(app.focus, Focus::Sessions, "back on the columns"); + let (_, text) = draw(&mut app); + assert!(text.contains("WORKTREES"), "{text}"); + } + + /// The labels of the menu `m` opened, and where it hangs. + fn menu_of(app: &App) -> (Vec, Option<(u16, u16)>) { + match &app.overlay { + Some(Overlay::Menu(m)) => (m.items.iter().map(|i| i.label.clone()).collect(), m.at), + other => panic!("no menu: {other:?}"), + } + } + + /// `m` opens the cursor row's menu — a right-click's, and only that — + /// at the fixed spot a keyboard menu always hung at; a session row's + /// menu is the session's alone, the checkout's verbs being its + /// WORKTREES row's. A column with no row under its cursor has none, + /// and neither has the pane. + #[test] + fn m_opens_the_cursor_rows_menu_alone() { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Worktrees; + draw(&mut app); + key(&mut app, 'm', &mut out); + let (labels, at) = menu_of(&app); + for want in ["New agent", "New terminal", "Open"] { + assert!(labels.iter().any(|l| l == want), "{want}: {labels:?}"); + } + for gone in ["New worktree", "Show/hide open PRs", "Show/hide issues"] { + assert!(!labels.iter().any(|l| l == gone), "{gone}: {labels:?}"); + } + assert_eq!(at, Some(super::KEYBOARD_MENU_ANCHOR)); + app.overlay = None; + app.focus = Focus::Sessions; + draw(&mut app); + key(&mut app, 'm', &mut out); + let (labels, at) = menu_of(&app); + for want in ["Attach", "Follow-up prompt", "Duplicate", "Archive"] { + assert!(labels.iter().any(|l| l == want), "{want}: {labels:?}"); + } + for gone in ["Run", "Delete worktree", "Show/hide archived"] { + assert!(!labels.iter().any(|l| l == gone), "{gone}: {labels:?}"); + } + assert_eq!(at, Some(super::KEYBOARD_MENU_ANCHOR)); + app.overlay = None; + app.focus = Focus::Terminal; + key(&mut app, 'm', &mut out); + assert!(app.overlay.is_none(), "{:?}", app.overlay); + // A checkout with no session: SESSIONS has no row, and no menu. + app.tree.agents.clear(); + app.focus = Focus::Sessions; + draw(&mut app); + key(&mut app, 'm', &mut out); + assert!(app.overlay.is_none(), "{:?}", app.overlay); + } + + /// The PANELS' own keys are no default binding of the GRID's, so + /// taking them beside the columns takes nothing from it — and on the + /// grid they still do nothing at all. + #[test] + fn the_panels_own_keys_leave_the_grid_alone() { + let keymap = crate::keymap::Keymap::default(); + for (k, _) in crate::panels::PANEL_KEYS { + let bound = keymap.lookup(crate::keymap::Scope::Global, &k); + assert_eq!(bound, None, "{} is the grid's", k.display()); + } + let mut app = panels_app(); + app.panels = false; + let mut out = Vec::new(); + draw(&mut app); + let focus = app.focus; + key(&mut app, 'z', &mut out); + key(&mut app, 'm', &mut out); + chord(&mut app, KeyCode::BackTab, KeyModifiers::SHIFT, &mut out); + chord(&mut app, KeyCode::Right, KeyModifiers::CONTROL, &mut out); + assert!(app.overlay.is_none() && !app.collapsed, "{:?}", app.overlay); + assert_eq!(app.focus, focus); + } + + /// The footer's hints on a column end `m: menu ?: help` beside the + /// columns, as they always did, and the grid's never name `m`. + #[test] + fn the_footer_names_m_beside_the_columns_only() { + let mut app = panels_app(); + app.focus = Focus::Projects; + let (_, text) = draw(&mut app); + assert!(text.contains("m: menu ?: help"), "{text}"); + app.panels = false; + let (_, text) = draw(&mut app); + assert!(!text.contains("m: menu"), "{text}"); + } + + /// The PANELS paint the FOCUS TINT each preset had while they were + /// the only layout; the GRID keeps the preset's own. + #[test] + fn the_panels_keep_their_old_focus_tint() { + use ratatui::style::Color; + let mut app = App::new(); + let mut cfg = crate::config::Config { + layout: "panels".into(), + ..Default::default() + }; + apply_config(&mut app, &cfg); + assert_eq!(app.theme.focus_tint, Color::Rgb(22, 33, 34)); + cfg.theme = "Ocean".into(); + apply_config(&mut app, &cfg); + assert_eq!(app.theme.focus_tint, Color::Rgb(21, 31, 38)); + cfg.layout = "grid".into(); + apply_config(&mut app, &cfg); + assert_eq!(app.theme, crate::theme::Theme::by_name("ocean")); + } + + /// The Appearance tab's column rows and RECENT PROMPTS sit under their + /// own header, never marked `(new)`: they came back with the layout. + #[test] + fn the_column_rows_sit_under_a_panels_header() { + use crate::config::{settings_rows, today_days, SettingKind, SettingsRow}; + let (tab, _) = crate::config::locate(SettingKind::HideProjects).unwrap(); + let rows = settings_rows(tab); + let header = rows + .iter() + .position(|r| *r == SettingsRow::Header("PANELS LAYOUT".into())) + .expect("the header"); + assert_eq!(rows.len() - header, 6, "the five rows close the tab"); + for kind in [ + SettingKind::HideProjects, + SettingKind::HideWorktrees, + SettingKind::HideSessions, + SettingKind::RecentPrompts, + SettingKind::RecentPromptsCount, + ] { + assert!(!kind.is_new(today_days()), "{kind:?}"); + } + } + + /// `⇧P` / `⇧B` / `⇧S` as the keys arrive from a terminal. + fn shift(app: &mut App, c: char, out: &mut Vec) { + press(app, KeyCode::Char(c), KeyModifiers::SHIFT, out); + } + + /// `⇧P` folds PROJECTS to its RAIL — the pane takes the width, a FOCUS + /// there steps on to WORKTREES, the footer names the key that opens + /// it — and `⇧P` again opens it at the width it was dragged to. The + /// fold is written to CONFIG.JSON as it happens. + #[test] + fn shift_p_folds_projects_to_a_rail_and_back() { + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.panels_widths = Some([30, 22, 32]); + app.focus = Focus::Projects; + draw(&mut app); + let pane = app.term_area; + shift(&mut app, 'P', &mut out); + assert_eq!(app.panels_hidden, [true, false, false]); + assert_eq!(app.focus, Focus::Worktrees, "FOCUS steps off the rail"); + assert!(crate::config::Config::load().hide_projects); + let (terminal, text) = draw(&mut app); + assert!(!text.contains("PROJECTS"), "{text}"); + assert!(text.contains("⇧P: show projects"), "{text}"); + assert_eq!(terminal.backend().buffer()[(0, 1)].symbol(), "▶"); + assert_eq!(app.term_area.x, pane.x - 30 + crate::panels::RAIL_W); + assert_eq!(app.panels_widths, Some([30, 22, 32]), "remembered"); + shift(&mut app, 'P', &mut out); + assert_eq!(app.panels_hidden, [false; 3]); + assert_eq!(app.focus, Focus::Worktrees, "opening takes no FOCUS"); + assert!(!crate::config::Config::load().hide_projects); + let (_, text) = draw(&mut app); + assert!(text.contains("PROJECTS") && !text.contains("show projects")); + assert_eq!(app.term_area, pane, "back at its width"); + }); + } + + /// `⇧B` folds WORKTREES and `⇧S` SESSIONS the same way, each on its + /// own; the GRID keeps what the keymap binds the chords to. + #[test] + fn shift_b_and_shift_s_fold_their_columns_only_beside_them() { + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Projects; + draw(&mut app); + shift(&mut app, 'B', &mut out); + shift(&mut app, 'S', &mut out); + assert_eq!(app.panels_hidden, [false, true, true]); + let (_, text) = draw(&mut app); + assert!(text.contains("⇧B: show worktrees"), "{text}"); + assert!(text.contains("⇧S: show sessions"), "{text}"); + assert!(!text.contains("WORKTREES") && !text.contains("SESSIONS")); + let saved = crate::config::Config::load(); + assert!(saved.hide_worktrees && saved.hide_sessions); + app.panels = false; + app.panels_hidden = [false; 3]; + app.focus = Focus::Sessions; + draw(&mut app); + for c in ['P', 'B', 'S'] { + shift(&mut app, c, &mut out); + app.overlay = None; + } + assert_eq!(app.panels_hidden, [false; 3], "the grid has no columns"); + }); + } + + /// `^B` folds every column and puts FOCUS in the pane; again, with + /// none open, it brings them all back. `⇧Z` is the same key. + #[test] + fn ctrl_b_folds_every_column_and_brings_them_back() { + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Sessions; + draw(&mut app); + let pane = app.term_area; + press( + &mut app, + KeyCode::Char('b'), + KeyModifiers::CONTROL, + &mut out, + ); + assert_eq!(app.panels_hidden, [true; 3]); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.flash.as_deref(), Some("panels collapsed")); + let (_, text) = draw(&mut app); + let rails = 3 * crate::panels::RAIL_W; + assert_eq!(app.term_area.x, pane.x - (20 + 22 + 32) + rails, "{text}"); + shift(&mut app, 'Z', &mut out); + assert_eq!(app.panels_hidden, [false; 3]); + assert_eq!(app.flash.as_deref(), Some("panels expanded")); + assert_eq!(app.focus, Focus::Terminal, "FOCUS stays"); + // One column still open is enough to fold them all. + shift(&mut app, 'P', &mut out); + shift(&mut app, 'Z', &mut out); + assert_eq!(app.panels_hidden, [true; 3]); + }); + } + + /// `h` / `l` and Tab step over a folded column. + #[test] + fn the_walk_skips_a_folded_column() { + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.focus = Focus::Projects; + shift(&mut app, 'B', &mut out); + key(&mut app, 'l', &mut out); + assert_eq!(app.focus, Focus::Sessions); + key(&mut app, 'h', &mut out); + assert_eq!(app.focus, Focus::Projects); + press(&mut app, KeyCode::Tab, KeyModifiers::NONE, &mut out); + assert_eq!(app.focus, Focus::Sessions); + // PROJECTS folded too: `h` from SESSIONS has nowhere to go. + shift(&mut app, 'P', &mut out); + key(&mut app, 'h', &mut out); + assert_eq!(app.focus, Focus::Sessions); + }); + } + + /// The folds outlive a restart through CONFIG.JSON, PROJECTS folded + /// opening with FOCUS on the first open column; the Settings rows read + /// and flip the same keys. + #[test] + fn the_folds_outlive_a_restart() { + use crate::config::SettingKind; + with_config_json(r#"{"layout": "panels", "hide_projects": true}"#, || { + let mut app = panels_app(); + app.focus = Focus::Projects; + let mut cfg = crate::config::Config::load(); + apply_config(&mut app, &cfg); + assert_eq!(app.panels_hidden, [true, false, false]); + assert_eq!(app.focus, Focus::Worktrees); + assert_eq!(cfg.value_label(SettingKind::HideProjects), "hidden"); + let (tab, row) = crate::config::locate(SettingKind::HideSessions).unwrap(); + cfg.cycle(tab, row, 1); + apply_config(&mut app, &cfg); + assert_eq!(app.panels_hidden, [true, false, true]); + let (_, text) = draw(&mut app); + assert!(text.contains("⇧S: show sessions"), "{text}"); + }); + } + + /// The `◀` on a column's title folds it, and a click on its RAIL — the + /// one cell its neighbor's BORDER zone stops short of — opens it at + /// the width it had, arming no drag. + #[test] + fn the_chevron_folds_a_column_and_its_rail_opens_it() { + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.panels_widths = Some([24, 26, 32]); + app.focus = Focus::Sessions; + draw(&mut app); + let down = MouseEventKind::Down(MouseButton::Left); + let chevron = hit_rect(&app, HitTarget::PanelsFold(Focus::Worktrees)); + mouse_at(&mut app, down, (chevron.x, chevron.y), &mut out); + assert_eq!(app.panels_hidden, [false, true, false]); + assert_eq!(app.focus, Focus::Sessions, "FOCUS was elsewhere"); + assert!(crate::config::Config::load().hide_worktrees); + draw(&mut app); + let rail = hit_rect(&app, HitTarget::PanelsFold(Focus::Worktrees)); + assert_eq!((rail.x, rail.width), (24, crate::panels::RAIL_W)); + let zone = hit_rect(&app, HitTarget::PanelsBorder(0)); + assert_eq!((zone.x, zone.width), (23, 1), "stops at the rule"); + assert!(!app + .hits + .iter() + .any(|(_, h)| *h == HitTarget::PanelsBorder(1))); + mouse_at(&mut app, down, (rail.x, rail.y + 1), &mut out); + assert_eq!(app.panels_hidden, [false; 3]); + assert!(app.panels_drag.is_none(), "no drag armed"); + draw(&mut app); + assert_eq!( + hit_rect(&app, HitTarget::PanelBg(Focus::Worktrees)).width, + 26 + ); + // A click on the chevron of the column FOCUS is on steps it on. + app.focus = Focus::Projects; + let chevron = hit_rect(&app, HitTarget::PanelsFold(Focus::Projects)); + mouse_at(&mut app, down, (chevron.x, chevron.y), &mut out); + assert_eq!(app.focus, Focus::Worktrees); + }); + } + + /// The WORKTREES cursor on a pull request or an issue folds SESSIONS to + /// a bare rule — no chevron, no target, no restore hint — and the pane + /// reading the row takes its width; a FOCUS left on SESSIONS steps + /// back onto WORKTREES. The cursor back on a checkout opens it again, + /// and `hide_sessions` is never written by the fold. + #[test] + fn sessions_folds_to_a_rule_beside_a_pull_request_or_an_issue() { + use super::super::tests::{seed_issues, seed_open_prs}; + use crate::app::WorktreeRow; + with_config_json("{}", || { + let mut app = panels_app(); + let mut out = Vec::new(); + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "demo") + .unwrap(); + seed_open_prs(&mut app, &[(7, "a fix")]); + seed_issues(&mut app, &[(9, "a bug")]); + draw(&mut app); + let pane = app.term_area; + let rows = app.worktree_rows(); + let pr = rows.iter().position(|r| matches!(r, WorktreeRow::Pr(_))); + let issue = rows.iter().position(|r| matches!(r, WorktreeRow::Issue(_))); + for at in [pr.unwrap(), issue.unwrap()] { + app.sel_worktree = at; + app.focus = Focus::Sessions; + let (_, text) = draw(&mut app); + assert_eq!(crate::panels::folds(&app)[2], crate::panels::Fold::Rule); + assert_eq!(app.focus, Focus::Worktrees, "FOCUS steps back"); + assert!(!text.contains("SESSIONS"), "{text}"); + assert!(!text.contains("show sessions"), "{text}"); + assert!(!app.hits.iter().any(|(_, h)| matches!( + h, + HitTarget::PanelsFold(Focus::Sessions) | HitTarget::PanelBg(Focus::Sessions) + ))); + assert_eq!(app.term_area.x, pane.x - 32 + crate::panels::RAIL_W); + assert!(app.term_area.width > pane.width); + key(&mut app, 'l', &mut out); + assert_eq!(app.focus, Focus::Terminal, "l steps over the rule"); + app.focus = Focus::Worktrees; + } + assert!(!app.panels_hidden[2]); + assert!(!crate::config::Config::load().hide_sessions); + app.sel_worktree = 0; + let (_, text) = draw(&mut app); + assert!(text.contains("SESSIONS"), "{text}"); + assert_eq!(app.term_area, pane); + }); + } + + /// `?` beside the columns lists the keys that fold them. + #[test] + fn question_mark_lists_the_fold_keys() { + let mut app = panels_app(); + let mut out = Vec::new(); + draw(&mut app); + key(&mut app, '?', &mut out); + let text = draw_tall(&mut app); + for want in [ + "⇧P / ⇧B / ⇧S", + "collapse / expand Projects", + "^b ⇧Z", + "collapse every panel", + ] { + assert!(text.contains(want), "{want}: {text}"); + } + } + + /// `demo`'s root checkout under the cursors, `agent-1` with `beta` + /// beside it (newer, so first), the SESSIONS cursor on the first row. + fn demo_root(app: &mut App) { + app.sel_project = app + .project_rows() + .iter() + .position(|i| app.tree.projects[*i].name == "demo") + .unwrap(); + app.sel_worktree = 0; + let beta = Agent { + id: AgentId("b1".into()), + name: "beta".into(), + status_changed_at: 5, + ..app.tree.agents[0].clone() + }; + hse( + app, + ServerEvent::EntityUpserted { + entity: Entity::Agent(beta), + }, + ); + app.sel_session = 0; + app.focus = Focus::Sessions; + } + + /// The agent under the SESSIONS cursor. + fn selected_agent(app: &App) -> AgentId { + match app.selected_session_row() { + Some(crate::app::SessionRow::Agent(a)) => a.id, + other => panic!("no agent row: {other:?}"), + } + } + + fn follow_up_text(app: &App) -> Option { + app.follow_up.as_ref().map(|f| f.input.as_str().to_string()) + } + + /// Space expands the session's pill in place into its FOLLOW-UP + /// COMPOSER — no modal — its chevron flipping to `▾`, the box straight + /// under the name and the pill below pushed down by the box and the + /// bottom pad the pill keeps. The box owns the keys: `a` is a letter, + /// not an archive, and Enter sends the turn down the PTY and folds the + /// pill back up. + #[test] + fn space_expands_the_session_pill_into_its_composer() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + let (_, text) = draw(&mut app); + assert!(text.contains('▸') && !text.contains("follow-up"), "{text}"); + let name = hit_rect(&app, HitTarget::PanelsRow(Row::Session(0))); + let below = hit_rect(&app, HitTarget::PanelsRow(Row::Session(1))); + let id = selected_agent(&app); + key(&mut app, ' ', &mut out); + assert!(app.overlay.is_none(), "{:?}", app.overlay); + assert_eq!(app.follow_up.as_ref().map(|f| f.agent.clone()), Some(id)); + let (terminal, text) = draw(&mut app); + assert!(text.contains('▾'), "{text}"); + let buf = terminal.backend().buffer(); + let line = |y: u16| -> String { + (name.x..name.right()) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + }; + assert!( + line(name.y + 2).contains("╭─ follow-up"), + "{}", + line(name.y + 2) + ); + assert!(line(name.y + 4).contains("Esc"), "{}", line(name.y + 4)); + let moved = hit_rect(&app, HitTarget::PanelsRow(Row::Session(1))); + assert_eq!(moved.y, below.y + 4, "three rows of box and the bottom pad"); + for c in "a fix".chars() { + key(&mut app, c, &mut out); + } + assert_eq!(follow_up_text(&app).as_deref(), Some("a fix")); + assert!( + app.overlay.is_none(), + "`a` archived nothing: {:?}", + app.overlay + ); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(app.follow_up.is_none(), "sent, and folded back up"); + let sent: Vec<&[u8]> = out + .iter() + .filter_map(|r| match r { + ClientRequest::Input { data, .. } => Some(data.as_slice()), + _ => None, + }) + .collect(); + assert_eq!(sent, [b"a fix".as_slice(), b"\r".as_slice()]); + } + + /// Esc folds the pill without sending; a paste lands in the box, + /// newlines and all; Tab walks on and leaves the box open behind it. + #[test] + fn the_composer_folds_on_esc_and_takes_a_paste() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + key(&mut app, ' ', &mut out); + assert!(super::super::paste_into_follow_up(&mut app, "one\ntwo")); + assert_eq!(follow_up_text(&app).as_deref(), Some("one\ntwo")); + let (_, text) = draw(&mut app); + assert!(text.contains("one") && text.contains("two"), "{text}"); + press(&mut app, KeyCode::Tab, KeyModifiers::NONE, &mut out); + assert_eq!(app.focus, Focus::Terminal, "Tab walks on"); + assert!(app.follow_up.is_some(), "the box stays behind"); + app.focus = Focus::Sessions; + press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); + assert!(app.follow_up.is_none()); + assert!( + !out.iter().any(|r| matches!(r, ClientRequest::Input { .. })), + "{out:?}" + ); + } + + /// The chevron is its own click target: a click expands that pill — + /// the cursor landing on it first — and a second folds it. A click in + /// the open box only gives SESSIONS FOCUS, never attaching; a click on + /// another pill folds the box. + #[test] + fn the_chevron_toggles_the_composer_and_the_box_is_its_own_target() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + let down = MouseEventKind::Down(MouseButton::Left); + let chevron = hit_rect(&app, HitTarget::PanelsRow(Row::FollowUp(1))); + assert_eq!(chevron.width, 2); + mouse_at(&mut app, down, (chevron.x + 1, chevron.y), &mut out); + assert_eq!(app.sel_session, 1, "the cursor came along"); + assert_eq!(app.follow_up_row(), Some(1)); + draw(&mut app); + app.focus = Focus::Worktrees; + let inside = hit_rect(&app, HitTarget::PanelsRow(Row::FollowUpBox)); + assert_eq!(inside.height, 3); + out.clear(); + mouse_at(&mut app, down, (inside.x + 3, inside.y + 1), &mut out); + mouse_at(&mut app, down, (inside.x + 3, inside.y + 1), &mut out); + assert_eq!(app.focus, Focus::Sessions); + assert!(app.follow_up.is_some() && !app.term_locked); + assert!( + !out.iter() + .any(|r| matches!(r, ClientRequest::Attach { .. })), + "{out:?}" + ); + let chevron = hit_rect(&app, HitTarget::PanelsRow(Row::FollowUp(1))); + mouse_at(&mut app, down, (chevron.x, chevron.y), &mut out); + assert!(app.follow_up.is_none(), "a second click folds it"); + key(&mut app, ' ', &mut out); + draw(&mut app); + let other = hit_rect(&app, HitTarget::PanelsRow(Row::Session(0))); + mouse_at(&mut app, down, (other.x + 2, other.y + 1), &mut out); + assert!(app.follow_up.is_none(), "another pill folds the box"); + assert_eq!(app.sel_session, 0); + } + + /// Only a live local agent wears a chevron or takes Space: an archived + /// row says why not, and so does a terminal. + #[test] + fn only_a_live_agent_takes_a_follow_up() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + app.tree.terminals.push(nebula_core::TerminalTab { + id: nebula_core::TerminalId("t1".into()), + worktree_id: WorktreeId("w1".into()), + name: "shell".into(), + sort_order: 0, + alive: true, + run_command: None, + }); + draw(&mut app); + let rows = app.visible_session_rows(); + let terminal = rows + .iter() + .position(|r| matches!(r, crate::app::SessionRow::Terminal(_))) + .unwrap(); + assert!(!app + .hits + .iter() + .any(|(_, h)| *h == HitTarget::PanelsRow(Row::FollowUp(terminal)))); + app.sel_session = terminal; + key(&mut app, ' ', &mut out); + assert!(app.follow_up.is_none()); + assert!(app.flash.take().is_some(), "says what a terminal takes"); + } + + /// The menu's **Follow-up prompt** beside the columns expands the pill, + /// as Space does, rather than opening the grid's modal. + #[test] + fn the_menus_follow_up_expands_the_pill() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + super::super::run_menu_action(&mut app, crate::app::MenuAction::FollowUp, &mut out); + assert!( + !matches!( + &app.overlay, + Some(Overlay::Prompt(p)) if matches!(p.kind, PromptKind::FollowUp { .. }) + ), + "{:?}", + app.overlay + ); + assert_eq!(app.follow_up_row(), Some(0)); + } + + /// In a column taller than the screen the window follows the expanded + /// pill, and keeps its box on screen as it grows. + #[test] + fn the_scroll_keeps_the_open_box_on_screen() { + let mut app = panels_app(); + let mut out = Vec::new(); + long_sessions(&mut app, 40); + app.focus = Focus::Sessions; + draw(&mut app); + for _ in 0..11 { + key(&mut app, 'j', &mut out); + } + draw(&mut app); + key(&mut app, ' ', &mut out); + for _ in 0..3 { + press( + &mut app, + KeyCode::Char('j'), + KeyModifiers::CONTROL, + &mut out, + ); + key(&mut app, 'x', &mut out); + } + draw(&mut app); + let list = hit_rect(&app, HitTarget::PanelBg(Focus::Sessions)); + let inside = hit_rect(&app, HitTarget::PanelsRow(Row::FollowUpBox)); + assert_eq!(inside.height, 2 + 4, "four lines of typing"); + assert!(inside.bottom() <= list.bottom(), "{inside:?} in {list:?}"); + } + + /// Three prompts in agent `id`'s history, oldest first. + fn with_prompts(app: &mut App, id: &str) { + let at = app.tree.agents.iter().position(|a| a.id.0 == id).unwrap(); + let now = crate::app::now_ms(); + app.tree.agents[at].recent_prompts = ["fix the redirect", "add a test", "why is CI slow"] + .iter() + .enumerate() + .map(|(i, text)| nebula_core::PromptEntry { + text: (*text).into(), + submitted_at: now - (3 - i as i64) * 60_000, + }) + .collect(); + } + + /// With RECENT PROMPTS on, a session's pill lists its newest prompts + /// under its name — oldest first, the newest at the bottom — and grows + /// by them, keeping its bottom pad; off, the pill is two rows again. + /// The setting is read through CONFIG.JSON. + #[test] + fn recent_prompts_hang_under_a_session_pill() { + with_config_json( + r#"{"layout": "panels", "recent_prompts": true, "recent_prompts_count": 2}"#, + || { + let mut app = panels_app(); + apply_config(&mut app, &crate::config::Config::load()); + assert_eq!(app.recent_prompts, 2); + demo_root(&mut app); + with_prompts(&mut app, "b1"); + let (terminal, text) = draw(&mut app); + assert!(!text.contains("fix the redirect"), "{text}"); + let pill = hit_rect(&app, HitTarget::PanelsRow(Row::Session(0))); + assert_eq!(pill.height, 3 + 2, "the pill grew by its two lines"); + let below = hit_rect(&app, HitTarget::PanelsRow(Row::Session(1))); + assert_eq!(below.y, pill.y + 5); + let buf = terminal.backend().buffer(); + let line = |y: u16| -> String { + (pill.x..pill.right()) + .map(|x| buf[(pill.x.max(x), y)].symbol().to_string()) + .collect() + }; + assert!( + line(pill.y + 2).contains("· add a test"), + "{}", + line(pill.y + 2) + ); + assert!( + line(pill.y + 3).contains("· why is CI slow"), + "{}", + line(pill.y + 3) + ); + assert!(line(pill.y + 3).contains("ago"), "{}", line(pill.y + 3)); + // The FOLLOW-UP COMPOSER opens under the prompts. + key(&mut app, ' ', &mut Vec::new()); + draw(&mut app); + let inside = hit_rect(&app, HitTarget::PanelsRow(Row::FollowUpBox)); + assert_eq!(inside.y, pill.y + 4); + apply_config(&mut app, &crate::config::Config::default()); + app.panels = true; + app.follow_up = None; + draw(&mut app); + let pill = hit_rect(&app, HitTarget::PanelsRow(Row::Session(0))); + assert_eq!(pill.height, 2, "off: the pill shares its pad again"); + }, + ); + } + + /// A click on an archived session selects it and says why it neither + /// previews nor attaches; a second click attaches nothing. + #[test] + fn a_click_on_an_archived_session_says_why() { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + app.show_archived = true; + let at = app.tree.agents.iter().position(|a| a.id.0 == "b1").unwrap(); + app.tree.agents[at].archived = true; + draw(&mut app); + let rows = app.visible_session_rows(); + let gone = rows.iter().position(|r| r.is_archived_agent()).unwrap(); + let rect = hit_rect(&app, HitTarget::PanelsRow(Row::Session(gone))); + let down = MouseEventKind::Down(MouseButton::Left); + mouse_at(&mut app, down, (rect.x + 2, rect.y + 1), &mut out); + mouse_at(&mut app, down, (rect.x + 2, rect.y + 1), &mut out); + assert_eq!(app.flash.as_deref(), Some(super::super::AGENT_ARCHIVED)); + assert!(!app.term_locked); } } diff --git a/crates/nebula-tui/src/panels.rs b/crates/nebula-tui/src/panels.rs index db7244d3..ac722f4e 100644 --- a/crates/nebula-tui/src/panels.rs +++ b/crates/nebula-tui/src/panels.rs @@ -17,15 +17,23 @@ //! //! What lives here is what the layout adds: the columns' widths //! ([`columns`]) and the drag of a column's BORDER that changes them -//! ([`drag_border`]), the lines each column lays out ([`project_lines`], -//! [`worktree_lines`], [`session_lines`]) and the scroll that keeps the -//! cursor's line on screen ([`scroll_to`]) until the wheel moves it -//! ([`ColumnScroll`]). The keys are -//! `event_loop::panels`'s and the drawing `ui::panels_view`'s. +//! ([`drag_border`]), each column's FOLD — open, a RAIL folded by hand, +//! or the bare RULE SESSIONS folds to beside a pull request or an issue +//! ([`folds`]) — the rows each column lays its entries out on — +//! 3-row PROJECT buttons, 2-row PILLS under them ([`project_lines`], +//! [`worktree_lines`], [`session_lines`]), a session's pill growing by +//! its RECENT PROMPTS ([`prompt_lines`]) and its FOLLOW-UP COMPOSER +//! ([`follow_up_rows`]) — and the scroll that keeps the cursor's row on +//! screen until the wheel moves it ([`ColumnScroll`]). +//! The keys are `event_loop::panels`'s and the drawing +//! `ui::panels_view`'s. use crate::app::{App, Focus, SessionRow, WorktreeRow}; +use crate::keymap::KeyChord; +use crossterm::event::{KeyCode, KeyModifiers}; use nebula_core::{ProjectId, WorktreeId}; use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Color; /// Widths of the PROJECTS, WORKTREES and SESSIONS columns on a body wide /// enough for them and the pane, until a BORDER is dragged @@ -35,6 +43,182 @@ pub const WIDTHS: [u16; 3] = [20, 22, 32]; pub const MIN_W: u16 = 10; /// Columns the PANE always keeps, whatever the columns would like. pub const MIN_PANE_W: u16 = 20; +/// Columns a folded column keeps: its rule alone, a RAIL's expand +/// chevron drawn over it on the title row. The width the column was +/// dragged to is untouched while it is folded, so it opens back up to it. +pub const RAIL_W: u16 = 1; + +/// How a column stands this frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fold { + /// At its width, its rows listed under its title. + Open, + /// A RAIL: folded by hand — its title's `◀`, its `⇧` key, `^B` — + /// down to its rule with a `▶` over it, which a click opens again. + Rail, + /// The bare rule, no chevron and no click target: SESSIONS beside a + /// pull request or an issue under the WORKTREES cursor, which has no + /// sessions to list ([`sessions_fold`]). It opens again of itself the + /// moment the cursor steps onto a checkout. + Rule, +} + +/// `key` held with `mods`, as `KeyChord::from_event` spells it. +const fn chord(key: char, mods: KeyModifiers) -> KeyChord { + KeyChord { + code: KeyCode::Char(key), + mods, + } +} + +/// The keys that fold PROJECTS, WORKTREES (`B` for branches) and SESSIONS +/// to their RAILS, or open them again: `⇧P`, `⇧B`, `⇧S`, the keys the +/// columns always folded by. Fixed rather than rebindable, and ahead of +/// whatever the keymap binds them to — `⇧P` is the GRID's **Duplicate +/// session**, which the panels reach from a row's right-click menu — but +/// only beside the columns: the GRID keeps its own meanings +/// (`event_loop::panels::fold_key`). +pub const FOLD_KEYS: [KeyChord; 3] = [ + chord('p', KeyModifiers::SHIFT), + chord('b', KeyModifiers::SHIFT), + chord('s', KeyModifiers::SHIFT), +]; +/// The keys that fold every column at once, or open them all: `^B`, `⌘B` +/// where the emulator sends it, and `⇧Z` for a tmux whose prefix eats +/// `^B`. +pub const FOLD_ALL_KEYS: [KeyChord; 3] = [ + chord('b', KeyModifiers::CONTROL), + chord('b', KeyModifiers::SUPER), + chord('z', KeyModifiers::SHIFT), +]; + +/// What one of the PANELS' own keys ([`PANEL_KEYS`]) does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelKey { + /// `⇧Tab` / `^⇧H`: FOCUS one open column left, stopping at the first + /// — the backward twin of Tab. + FocusPrev, + /// `^⇧L`: Tab's twin, for an emulator with the kitty protocol. + FocusNext, + /// `^→`: FOCUS one step right without taking the input lock, so the + /// pane is reached and read without typing into it. + FocusTerminal, + /// `z`: the pane FULL-SCREEN with the input lock on. + Zoom, + /// `m`: the CONTEXT MENU of the row under the focused column's cursor. + ContextMenu, + /// `⇧C`: a Ghostty tab in the selected checkout, as `⇧T` is. + OpenGhosttyTab, +} + +/// `code` held with `mods`, for a key that is no character. +const fn key(code: KeyCode, mods: KeyModifiers) -> KeyChord { + KeyChord { code, mods } +} + +/// The keys the columns always answered to that the GRID dropped or never +/// had: fixed rather than rebindable, ahead of whatever the keymap binds +/// them to, and only beside the columns, as [`FOLD_KEYS`] are +/// (`event_loop::panels::panel_key`). `^⇧H` is also a hatch out of a +/// LOCKED PANE; that one is the pane's, read before any of these. +pub const PANEL_KEYS: [(KeyChord, PanelKey); 7] = [ + (key(KeyCode::Tab, KeyModifiers::SHIFT), PanelKey::FocusPrev), + ( + chord('h', KeyModifiers::CONTROL.union(KeyModifiers::SHIFT)), + PanelKey::FocusPrev, + ), + ( + chord('l', KeyModifiers::CONTROL.union(KeyModifiers::SHIFT)), + PanelKey::FocusNext, + ), + ( + key(KeyCode::Right, KeyModifiers::CONTROL), + PanelKey::FocusTerminal, + ), + (chord('z', KeyModifiers::NONE), PanelKey::Zoom), + (chord('m', KeyModifiers::NONE), PanelKey::ContextMenu), + (chord('c', KeyModifiers::SHIFT), PanelKey::OpenGhosttyTab), +]; + +/// Whether SESSIONS has nothing to list: the WORKTREES cursor is on an +/// `OPEN PRS` or `ISSUES` row, neither of which has a checkout and so +/// sessions, and the pane beside it reads the pull request or the issue +/// — so the column folds to its RULE and gives the pane its width for as +/// long as the cursor rests there. `App::panels_hidden` is untouched +/// either way: a column folded by hand stays a RAIL on the checkout too, +/// and this fold is never written to CONFIG.JSON. +pub fn sessions_fold(app: &App) -> bool { + app.selected_worktree_pr().is_some() || app.selected_worktree_issue().is_some() +} + +/// The three columns' folds, PROJECTS first: a RAIL where +/// `App::panels_hidden` says so, SESSIONS' RULE where [`sessions_fold`] +/// does, the rest open. +pub fn folds(app: &App) -> [Fold; 3] { + let by_hand = |hidden: bool| if hidden { Fold::Rail } else { Fold::Open }; + let [projects, worktrees, sessions] = app.panels_hidden; + let sessions = if !sessions && sessions_fold(app) { + Fold::Rule + } else { + by_hand(sessions) + }; + [by_hand(projects), by_hand(worktrees), sessions] +} + +/// The FOCUS TINT the PANELS paint for colour theme `theme` (a +/// CONFIG.JSON `theme`, read as `Theme::by_name` reads it): the faintly +/// lit gray each preset had while the columns were the only layout, where +/// the GRID's `Theme::focus_tint` is the accent taken down to near-black. +/// `event_loop::apply_config` puts it in the app's theme while the layout +/// is `panels`; every other colour is the preset's own. +pub fn focus_tint(theme: &str) -> Color { + let (r, g, b) = match theme.trim().to_ascii_lowercase().as_str() { + "ocean" => (21, 31, 38), + "forest" => (26, 34, 27), + "rose" => (37, 28, 32), + "amber" => (37, 32, 22), + "lavender" => (30, 28, 38), + "coral" => (38, 28, 26), + "slate" => (27, 30, 36), + "sand" => (36, 32, 27), + "mono" => (30, 30, 30), + _ => (22, 33, 34), + }; + Color::Rgb(r, g, b) +} + +/// The column FOCUS `focus` names, PROJECTS first; None for the pane. +pub fn column_index(focus: Focus) -> Option { + match focus { + Focus::Projects => Some(0), + Focus::Worktrees => Some(1), + Focus::Sessions => Some(2), + Focus::Terminal => None, + } +} + +/// Whether FOCUS can stand on `focus`: the pane always, a column while it +/// is open — the walk (`h`/`l`, Tab) steps over a folded one. +pub fn focus_open(app: &App, focus: Focus) -> bool { + column_index(focus).is_none_or(|i| folds(app)[i] == Fold::Open) +} + +/// FOCUS stepped off a column that has folded under it — by a key, a +/// click, a SETTING, or the SESSIONS fold a cursor's move brings on — +/// onto the nearest open column to the left, the way `⇧Tab` walks, or on +/// to the next one, the pane at the end, when none is open. Nothing to +/// do while it stands on an open column, or the pane. +pub fn settle_focus(app: &mut App) { + if focus_open(app, app.focus) { + return; + } + let back = app.previous_visible_focus(app.focus); + app.focus = if back == app.focus { + app.next_visible_focus(app.focus) + } else { + back + }; +} /// Where a row — or a header that folds — sits in the PANELS, as a click /// target (`HitTarget::PanelsRow`). The indices are the cursors': a @@ -52,6 +236,15 @@ pub enum Row { /// The SESSIONS column's `ARCHIVED` header: a click opens the group /// or folds it, as `⇧A` does. ArchivedHeader, + /// Session row `i`'s FOLLOW-UP CHEVRON — the `▸` / `▾` at the end of + /// its text row: a click puts the cursor on the row and expands its + /// pill into the FOLLOW-UP COMPOSER, or folds it, as Space does. + FollowUp(usize), + /// Inside the open FOLLOW-UP COMPOSER: a click there gives SESSIONS + /// FOCUS and stops, never a second click on the pill under it — which + /// would attach the session and lock the pane out from under the + /// typing. + FollowUpBox, } impl Row { @@ -60,16 +253,29 @@ impl Row { match self { Row::Project(_) => Focus::Projects, Row::Worktree(_) | Row::OpenPrsHeader | Row::IssuesHeader => Focus::Worktrees, - Row::Session(_) | Row::ArchivedHeader => Focus::Sessions, + Row::Session(_) | Row::ArchivedHeader | Row::FollowUp(_) | Row::FollowUpBox => { + Focus::Sessions + } } } } -/// One line a column lays out, top to bottom. +/// Rows a PROJECT button is tall: its name on the middle one, a row of +/// air above and below for the selection fill to wrap. A renamed project +/// grows by the row its folder's name takes under the name. +pub const PROJECT_BTN_H: usize = 3; +/// The stride WORKTREES and SESSIONS rows stack on. Each is a PILL — a +/// 3-row cell, half-block pad, text, half-block pad — and a pill's bottom +/// pad is the next one's top pad, so the rows sit two apart with no air +/// of their own and the step down from the PROJECT buttons reads in text +/// weight rather than spacing. +pub const PILL_H: usize = 2; +/// Rows one PILL covers on its own: its two pads and its text. +pub const PILL_CELL: usize = PILL_H + 1; + +/// What one entry of a column is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Line { - /// Air between two groups. - Blank, /// A group's name over its rows — `TERMINALS`, `▾ OPEN PRS · 3`. /// `fold` is the header's own click target, for a group that folds. Header { text: String, fold: Option }, @@ -77,19 +283,98 @@ pub enum Line { Row(Row), } +/// One entry of a column laid out in rows: where it starts down the +/// column, as though the column were tall enough for all of it, and how +/// many rows it covers. Two PILLS overlap by the pad they share, so an +/// entry's `top + height` can run into the next one's `top`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Placed { + pub top: usize, + pub height: usize, + pub line: Line, +} + +/// A column's entries in the order they stack, each at its row +/// ([`Placed`]): what the drawing walks, what the scroll measures. +pub type Column = Vec; + +/// Rows `column` runs down, the last entry's bottom pad and all. +pub fn content_height(column: &[Placed]) -> usize { + column.iter().map(|p| p.top + p.height).max().unwrap_or(0) +} + +/// Lays a column out top to bottom: the rows each entry takes, and the +/// line of air above every group after the first. +#[derive(Default)] +struct Stack { + column: Column, + next: usize, +} + +impl Stack { + /// A group's header, a line of air above it unless it opens the + /// column. After a PILL the air is the pill's own bottom pad, so the + /// header sits straight under it. + fn header(&mut self, text: String, fold: Option) { + if self.next > 0 { + self.next += 1; + } + self.place(1, 1, Line::Header { text, fold }); + } + + /// A PILL, stacked on the [`PILL_H`] stride. + fn pill(&mut self, row: Row) { + self.pill_with(row, 0); + } + + /// A PILL with `inside` rows of its own under its text — a session's + /// RECENT PROMPTS and its FOLLOW-UP COMPOSER — which it grows by, + /// keeping its bottom pad, so the next pill starts below that. + fn pill_with(&mut self, row: Row, inside: usize) { + if inside == 0 { + self.place(PILL_CELL, PILL_H, Line::Row(row)); + } else { + let height = PILL_CELL + inside; + self.place(height, height, Line::Row(row)); + } + } + + /// A button `height` rows tall, the next one straight under it. + fn button(&mut self, row: Row, height: usize) { + self.place(height, height, Line::Row(row)); + } + + /// A quiet row between two entries. + fn air(&mut self) { + self.next += 1; + } + + fn place(&mut self, height: usize, stride: usize, line: Line) { + self.column.push(Placed { + top: self.next, + height, + line, + }); + self.next += stride; + } +} + /// The four rects of the PANELS: the three columns at the widths they were -/// dragged to — [`WIDTHS`] until then — and the PANE taking every column -/// left over. A body too narrow for that squeezes the columns in -/// proportion — down to [`MIN_W`] each — so the pane keeps [`MIN_PANE_W`] -/// for as long as the window allows. The squeeze is the frame's alone: -/// the widths remembered are untouched, so a window that grows back opens -/// the columns back up to them. +/// dragged to — [`WIDTHS`] until then — a folded one at [`RAIL_W`], and +/// the PANE taking every column left over. A body too narrow for that +/// squeezes the open columns in proportion — down to [`MIN_W`] each — so +/// the pane keeps [`MIN_PANE_W`] for as long as the window allows. The +/// squeeze and the fold are the frame's alone: the widths remembered are +/// untouched, so a window that grows back, or a column opened again, +/// comes back to them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Columns { pub projects: Rect, pub worktrees: Rect, pub sessions: Rect, pub pane: Rect, + /// Each column's [`Fold`] as laid out, PROJECTS first. + pub folds: [Fold; 3], } impl Columns { @@ -122,30 +407,84 @@ impl Columns { /// Column `i`'s BORDER as a drag target (`HitTarget::PanelsBorder`): /// its rule and the cell after it, down the whole body — the two - /// touching cells every splitter grabs by. - pub fn grab_zone(&self, i: usize) -> Rect { - Rect { + /// touching cells every splitter grabs by. Beside a folded column the + /// zone stops at the rule: the column after it is one cell wide, and + /// that cell is a RAIL's expand chevron, which a click must reach + /// rather than arm a drag on its neighbor. None for a folded column, + /// which has no width to drag. + pub fn grab_zone(&self, i: usize) -> Option { + if self.folds[i] != Fold::Open { + return None; + } + let beside_fold = self.folds.get(i + 1).is_some_and(|f| *f != Fold::Open); + Some(Rect { x: self.border(i).saturating_sub(1), - width: 2, + width: if beside_fold { 1 } else { 2 }, ..self.column(i) - } + }) } } -/// Lay the PANELS out over `body` ([`Columns`]), the columns at `widths` — -/// [`WIDTHS`] when None. -pub fn columns(body: Rect, widths: Option<[u16; 3]>) -> Columns { +/// Lay the PANELS out over `body` ([`Columns`]), the open columns at +/// `widths` — [`WIDTHS`] when None — and the folded ones at [`RAIL_W`]. +pub fn columns(body: Rect, widths: Option<[u16; 3]>, folds: [Fold; 3]) -> Columns { let widths = widths.unwrap_or(WIDTHS); - let want: u16 = widths.iter().sum(); - let budget = body.width.saturating_sub(MIN_PANE_W); - let widths = widths.map(|w| { - if budget >= want { - w - } else { - (u32::from(w) * u32::from(budget) / u32::from(want.max(1))) as u16 + let open = |i: usize| folds[i] == Fold::Open; + let rails = (0..3).filter(|i| !open(*i)).count() as u16 * RAIL_W; + let budget = body.width.saturating_sub(MIN_PANE_W.saturating_add(rails)); + let requested: [u32; 3] = widths.map(|w| u32::from(w.max(MIN_W))); + let requested_total: u32 = (0..3).filter(|i| open(*i)).map(|i| requested[i]).sum(); + let available = u32::from(budget); + let mut fitted = [0u32; 3]; + if requested_total <= available { + for i in 0..3 { + fitted[i] = if open(i) { + requested[i] + } else { + u32::from(RAIL_W) + }; } - .max(MIN_W) - }); + } else { + let open_count = folds.iter().filter(|f| **f == Fold::Open).count() as u32; + let floor_total = open_count * u32::from(MIN_W); + if available >= floor_total { + // Iteratively pin columns whose proportional share falls below + // MIN_W, then divide the remainder among the other columns. + let mut remaining = available; + let mut active: [bool; 3] = std::array::from_fn(open); + loop { + let weight: u32 = (0..3).filter(|i| active[*i]).map(|i| requested[i]).sum(); + let below_floor: [bool; 3] = std::array::from_fn(|i| { + active[i] && requested[i] * remaining < u32::from(MIN_W) * weight + }); + if !below_floor.iter().any(|below| *below) { + for i in 0..3 { + if active[i] && weight > 0 { + fitted[i] = requested[i] * remaining / weight; + } + } + break; + } + for i in 0..3 { + if below_floor[i] { + fitted[i] = u32::from(MIN_W); + remaining -= u32::from(MIN_W); + active[i] = false; + } + } + } + } + // Keep each open column's floor even when the whole layout is too + // small; Ratatui clips the resulting areas to the body as before. + for (i, width) in fitted.iter_mut().enumerate() { + *width = if open(i) { + (*width).max(u32::from(MIN_W)) + } else { + u32::from(RAIL_W) + }; + } + } + let widths = fitted.map(|w| w as u16); let [projects, worktrees, sessions, pane] = Layout::horizontal([ Constraint::Length(widths[0]), Constraint::Length(widths[1]), @@ -158,125 +497,142 @@ pub fn columns(body: Rect, widths: Option<[u16; 3]>) -> Columns { worktrees, sessions, pane, + folds, } } +/// The PANELS over `body` as `app` stands: its dragged widths and its +/// columns' folds ([`columns`]). +pub fn layout(app: &App, body: Rect) -> Columns { + columns(body, app.panels_widths, folds(app)) +} + /// The widths that leave column `i`'s BORDER at screen column `border`, -/// the other two kept as `body` lays them out this frame: the column held -/// to [`MIN_W`] at the narrow end and, at the wide one, to what leaves the -/// pane its [`MIN_PANE_W`]. None on a body with no room to widen or narrow -/// it at all — there is nothing a drag there could remember. +/// the other open column kept as `body` lays it out this frame and a +/// folded one at the width it opens back up to: the column held to +/// [`MIN_W`] at the narrow end and, at the wide one, to what leaves the +/// pane its [`MIN_PANE_W`] beside the others as drawn. None for a folded +/// column, and on a body with no room to widen or narrow it at all — +/// there is nothing a drag there could remember. pub fn drag_border( body: Rect, widths: Option<[u16; 3]>, + folds: [Fold; 3], i: usize, border: i32, ) -> Option<[u16; 3]> { - let cols = columns(body, widths); - let mut widths = cols.widths(); + if folds[i] != Fold::Open { + return None; + } + let cols = columns(body, widths, folds); + let drawn = cols.widths(); let left = cols.column(i).x; - let others: u16 = widths.iter().sum::() - widths[i]; + let others: u16 = drawn.iter().sum::() - drawn[i]; let max = body.width.saturating_sub(others + MIN_PANE_W); if max < MIN_W { return None; } + let remembered = widths.unwrap_or(WIDTHS); + let mut widths: [u16; 3] = std::array::from_fn(|j| { + if folds[j] == Fold::Open { + drawn[j] + } else { + remembered[j] + } + }); let want = (border - i32::from(left)).clamp(0, i32::from(u16::MAX)) as u16; widths[i] = want.clamp(MIN_W, max); Some(widths) } -/// The PROJECTS column: one row per project, most recently worked in +/// The PROJECTS column: one button per project, most recently worked in /// first — `App::project_rows`' own order, which is what `sel_project` -/// indexes. -pub fn project_lines(app: &App) -> Vec { - (0..app.project_rows().len()) - .map(|i| Line::Row(Row::Project(i))) - .collect() +/// indexes — a row taller for a renamed project, whose folder's name +/// hangs under its own. +pub fn project_lines(app: &App) -> Column { + let mut stack = Stack::default(); + for (i, at) in app.project_rows().into_iter().enumerate() { + let renamed = app.tree.projects[at].folder_subtitle().is_some(); + stack.button(Row::Project(i), PROJECT_BTN_H + usize::from(renamed)); + } + stack.column } /// The WORKTREES column: the project's checkouts — the ROOT WORKTREE -/// first, with a line of air under it — then the `OPEN PRS` group (its -/// pull requests, each with the checkout on its head branch nested under -/// it) and the `ISSUES` group, each under a header that folds it. A -/// folded group is its header alone, still counting what it holds. -pub fn worktree_lines(app: &App) -> Vec { +/// first, with a quiet row under it — then the `OPEN PRS` group (its pull +/// requests, each with the checkout on its head branch nested under it) +/// and the `ISSUES` group, each under a header that folds it. A folded +/// group is its header alone, still counting what it holds; so is an +/// OPEN PRS group whose every pull request is a draft kept out. +pub fn worktree_lines(app: &App) -> Column { let rows = app.worktree_rows(); - let mut lines = Vec::new(); - let mut prs_headed = false; - let mut issues_headed = false; - let gap = |lines: &mut Vec| { - if !lines.is_empty() { - lines.push(Line::Blank); - } - }; - for (i, row) in rows.iter().enumerate() { - match row { - WorktreeRow::Checkout(w) => { - lines.push(Line::Row(Row::Worktree(i))); - let more = rows - .get(i + 1) - .is_some_and(|r| matches!(r, WorktreeRow::Checkout(_))); - if w.is_main && more { - lines.push(Line::Blank); - } - } - WorktreeRow::Pr(_) | WorktreeRow::PrCheckout(_) => { - if !prs_headed { - prs_headed = true; - gap(&mut lines); - lines.push(open_prs_header(app)); - } - lines.push(Line::Row(Row::Worktree(i))); - } - WorktreeRow::Issue(_) => { - if !prs_headed && app.open_prs_collapsed && !app.listed_open_prs().is_empty() { - prs_headed = true; - gap(&mut lines); - lines.push(open_prs_header(app)); - } - if !issues_headed { - issues_headed = true; - gap(&mut lines); - lines.push(issues_header(app)); - } - lines.push(Line::Row(Row::Worktree(i))); - } + let mut stack = Stack::default(); + let plain = rows + .iter() + .take_while(|r| matches!(r, WorktreeRow::Checkout(_))) + .count(); + for (i, row) in rows.iter().enumerate().take(plain) { + stack.pill(Row::Worktree(i)); + if matches!(row, WorktreeRow::Checkout(w) if w.is_main) && plain > 1 { + stack.air(); } } - // A folded group has no rows to have put its header up on the way. - if !prs_headed && !app.listed_open_prs().is_empty() { - gap(&mut lines); - lines.push(open_prs_header(app)); - } - if !issues_headed && !app.listed_issues().is_empty() { - gap(&mut lines); - lines.push(issues_header(app)); + // The issue rows close the list; everything between the plain + // checkouts and them is the OPEN PRS group. + let pr_end = rows + .iter() + .position(|r| matches!(r, WorktreeRow::Issue(_))) + .unwrap_or(rows.len()); + let listed = app.listed_open_prs().len(); + let open = app.all_open_prs().len(); + if listed > 0 || open > listed { + stack.header(open_prs_header(app, listed, open), Some(Row::OpenPrsHeader)); + // A checkout under its pull request stacks straight onto the pull + // request's pill: the two are one thing. + for i in plain..pr_end { + stack.pill(Row::Worktree(i)); + } } - lines -} - -/// `▾ OPEN PRS · 3` over the rows, `▸` with them folded away. -fn open_prs_header(app: &App) -> Line { - Line::Header { - text: format!( - "{} OPEN PRS · {}", - fold_glyph(app.open_prs_collapsed), - app.listed_open_prs().len() - ), - fold: Some(Row::OpenPrsHeader), + let issues = app.listed_issues().len(); + if issues > 0 { + // A hundred rows is not "a hundred issues": the answer hit the + // fetch cap, and the count says so as the pull requests' does. + let more = if issues >= crate::issues::LIST_LIMIT { + "+" + } else { + "" + }; + stack.header( + format!( + "{} ISSUES · {issues}{more}", + fold_glyph(app.issues_collapsed) + ), + Some(Row::IssuesHeader), + ); + for i in pr_end..rows.len() { + stack.pill(Row::Worktree(i)); + } } + stack.column } -/// `▾ ISSUES · 2`, the same way. -fn issues_header(app: &App) -> Line { - Line::Header { - text: format!( - "{} ISSUES · {}", - fold_glyph(app.issues_collapsed), - app.listed_issues().len() - ), - fold: Some(Row::IssuesHeader), - } +/// `▾ OPEN PRS · 3` over the rows, `▸` with them folded away. A list cut +/// off at the fetch cap says `100+` rather than passing itself off as +/// the whole set — the cap is on the answer, drafts and all, so it is +/// measured there — and with drafts kept out the count reads `9/12`: +/// nine rows listed of twelve open. +fn open_prs_header(app: &App, listed: usize, open: usize) -> String { + let more = if open >= crate::pull_request::LIST_LIMIT { + "+" + } else { + "" + }; + let count = if open > listed { + format!("{listed}/{open}{more}") + } else { + format!("{listed}{more}") + }; + format!("{} OPEN PRS · {count}", fold_glyph(app.open_prs_collapsed)) } /// The disclosure triangle: what a click on the header would do. @@ -288,39 +644,24 @@ fn fold_glyph(folded: bool) -> &'static str { } } -/// The SESSIONS column: the checkout's live sessions under `RECENT`, most -/// recently touched first, then its `TERMINALS`, its `PULL REQUESTS` and -/// last the `ARCHIVED` group — open (`⇧A`, or a click on its header) or -/// folded to the one line that counts it. The order is the one -/// `App::visible_session_rows` lists, which is what `sel_session` indexes. -pub fn session_lines(app: &App) -> Vec { +/// The SESSIONS column: the checkout's live sessions, most recently +/// touched first, as one list with no header of its own — the headers +/// under it name what isn't a live agent: its `TERMINALS`, its `PULL +/// REQUESTS` and last the ARCHIVED group, open (`ARCHIVED · 2`, by `⇧A` +/// or a click on its header) or folded to the one line that counts it +/// (`… 2 archived`). The order is the one `App::visible_session_rows` +/// lists, which is what `sel_session` indexes. A session's pill grows by +/// the RECENT PROMPTS under its text and, on the one expanded, by its +/// FOLLOW-UP COMPOSER, wrapped at the list's `width` — so opening the box +/// pushes every pill below it down the column. +pub fn session_lines(app: &App, width: u16) -> Column { let rows = app.visible_session_rows(); // The checkout's live and archived sessions, archived counted whether // the group is open or not: a folded group still says what it holds. - let checkout = app.selected_worktree().map(|w| w.id.clone()); let (live, archived) = app - .tree - .agents - .iter() - .filter(|a| Some(&a.worktree_id) == checkout.as_ref()) - .fold((0, 0), |(live, gone), a| { - if a.archived { - (live, gone + 1) - } else { - (live + 1, gone) - } - }); - let mut lines = Vec::new(); - let group = |lines: &mut Vec, title: &str, fold: Option, rows: &[usize]| { - if !lines.is_empty() { - lines.push(Line::Blank); - } - lines.push(Line::Header { - text: title.to_string(), - fold, - }); - lines.extend(rows.iter().map(|i| Line::Row(Row::Session(*i)))); - }; + .selected_worktree() + .map_or((0, 0), |w| app.group_counts_in(&w.id)); + let live = live.min(rows.len()); let of = |want: fn(&SessionRow) -> bool| -> Vec { rows.iter() .enumerate() @@ -328,39 +669,90 @@ pub fn session_lines(app: &App) -> Vec { .map(|(i, _)| i) .collect() }; - let recent: Vec = (0..live.min(rows.len())).collect(); let terminals = of(|row| matches!(row, SessionRow::Terminal(_))); let links = of(|row| matches!(row, SessionRow::Link(_))); let gone = of(SessionRow::is_archived_agent); - if !recent.is_empty() { - group(&mut lines, "RECENT", None, &recent); - } - if !terminals.is_empty() { - group(&mut lines, "TERMINALS", None, &terminals); + let mut stack = Stack::default(); + let pill = |stack: &mut Stack, i: usize| { + let inside = rows.get(i).map_or(0, |row| { + prompt_lines(app, row) + follow_up_rows(app, i, width) + }); + stack.pill_with(Row::Session(i), inside); + }; + for i in 0..live { + pill(&mut stack, i); } - if !links.is_empty() { - group(&mut lines, "PULL REQUESTS", None, &links); + for (title, group) in [("TERMINALS", &terminals), ("PULL REQUESTS", &links)] { + if !group.is_empty() { + stack.header(title.to_string(), None); + for i in group { + pill(&mut stack, *i); + } + } } if archived > 0 { let title = if app.show_archived { - format!("▾ ARCHIVED · {archived}") + format!("ARCHIVED · {archived}") } else { - format!("▸ ARCHIVED · {archived}") + format!("… {archived} archived") }; - group(&mut lines, &title, Some(Row::ArchivedHeader), &gone); + stack.header(title, Some(Row::ArchivedHeader)); + for i in &gone { + pill(&mut stack, *i); + } + } + stack.column +} + +/// How many RECENT PROMPTS lines a session row carries under its pill: +/// the `recent_prompts` setting (`App::recent_prompts`), capped at what the +/// session has. None for a terminal or a link (they take no prompts), an +/// archived session (its history is over, and the group is for scanning +/// names) or a QUICK PROMPT stand-in (its row has not been created yet). +pub fn prompt_lines(app: &App, row: &SessionRow) -> usize { + match row { + SessionRow::Agent(a) if !a.archived && !app.is_placeholder_agent(&a.id) => { + app.recent_prompts.min(a.recent_prompts.len()) + } + _ => 0, + } +} + +/// The most text rows the FOLLOW-UP COMPOSER grows to before it scrolls +/// under its own caret. Four is a paragraph of instruction in a 30-column +/// column; past that the box would own the column and push every pill +/// below it off the bottom for a prompt nobody reads back in full anyway. +pub const FOLLOW_UP_MAX_LINES: usize = 4; + +/// Rows the FOLLOW-UP COMPOSER takes inside session row `index`'s pill, in +/// a list `width` columns wide: the framed box — title row, text, hint row +/// — or 0 for every pill but the expanded one. The layout and the draw +/// both ask, so the height they agree on is computed once here from the +/// text as it wraps at this width. +pub fn follow_up_rows(app: &App, index: usize, width: u16) -> usize { + if app.follow_up_row() != Some(index) { + return 0; } - lines + let Some(follow_up) = &app.follow_up else { + return 0; + }; + let lines = crate::ui::multiline_input_lines( + &follow_up.input, + follow_up_text_width(width), + app.theme.accent, + app.theme, + ) + .0 + .len(); + 2 + lines.clamp(1, FOLLOW_UP_MAX_LINES) } -/// The first line of `lines` to draw in a column `height` rows tall, so -/// the line holding `cursor` is on screen: the stateless follow-window -/// every overlay list scrolls by (`app::window_start`), sliding only as -/// far as the cursor's line needs. The group header over a row is the -/// line above it, so it is on screen with the row whenever there is room. -pub fn scroll_to(lines: &[Line], cursor: Option, height: usize) -> usize { - cursor - .and_then(|c| lines.iter().position(|l| *l == Line::Row(c))) - .map_or(0, |at| crate::app::window_start(at, height.max(1))) +/// Columns of typing inside the composer's frame, in a list `width` +/// columns wide: the pill's rail column, the box's two borders and a space +/// either side of the text come off it first. Never 0 — a column dragged +/// down to [`MIN_W`] still has to wrap somewhere. +pub fn follow_up_text_width(width: u16) -> usize { + usize::from(width).saturating_sub(5).max(1) } /// Where the column FOCUS names keeps its scroll in `App::panels_scroll`. @@ -373,7 +765,7 @@ pub fn scroll_slot(focus: Focus) -> usize { } } -/// One column's scroll: the first line drawn, which the wheel moves under +/// One column's scroll: the first row drawn, which the wheel moves under /// a cursor that stays put and the cursor's own moves bring back on /// screen. The columns lay out and clamp it each frame ([`settle`]); the /// wheel (`event_loop::panels::wheel`) reads what that left in `max`. @@ -381,39 +773,56 @@ pub fn scroll_slot(focus: Focus) -> usize { /// [`settle`]: ColumnScroll::settle #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ColumnScroll { - /// The first line drawn. + /// The first row drawn. pub top: usize, - /// Furthest `top` goes: the last line on the bottom row, zero for a - /// column that fits. + /// Furthest `top` goes: the column's last row on the bottom one, zero + /// for a column that fits. pub max: usize, - /// The cursor and line count the last frame drew; a frame that finds - /// either changed reveals the cursor again. `None` is "reveal it". - seen: Option<(Option, usize)>, + /// The cursor, the entry count and the rows the cursor's entry covers + /// as the last frame drew them; a frame that finds any changed reveals + /// the cursor again — a FOLLOW-UP COMPOSER opening, or growing by a + /// line typed into it, among them. `None` is "reveal it". + seen: Option<(Option, usize, usize)>, } impl ColumnScroll { - /// The first line to draw for `lines` in a column `height` rows tall: - /// `top` held within the column, and slid only as far as the cursor's - /// line needs ([`scroll_to`]) when the cursor — or what the column - /// lists — is not what the last frame drew, so a wheeled-away cursor - /// stays away until it moves. - pub fn settle(&mut self, lines: &[Line], cursor: Option, height: usize) -> usize { + /// The first row to draw of `column` in a list `height` rows tall: + /// `top` held within the column, and — when the cursor, or what the + /// column lists, is not what the last frame drew — slid only as far + /// as brings the cursor's entry on screen, the header over it too + /// when it is the first of its group, so a wheeled-away cursor stays + /// away until it moves. + pub fn settle(&mut self, column: &[Placed], cursor: Option, height: usize) -> usize { let height = height.max(1); - self.max = lines.len().saturating_sub(height); - if self.seen != Some((cursor, lines.len())) { - self.seen = Some((cursor, lines.len())); - let at = cursor.and_then(|c| lines.iter().position(|l| *l == Line::Row(c))); - match at { - Some(at) if at < self.top => self.top = at, - Some(at) if at >= self.top + height => self.top = scroll_to(lines, cursor, height), - _ => {} + self.max = content_height(column).saturating_sub(height); + let at = cursor.and_then(|c| column.iter().position(|p| p.line == Line::Row(c))); + let tall = at.map_or(0, |at| column[at].height); + if self.seen != Some((cursor, column.len(), tall)) { + self.seen = Some((cursor, column.len(), tall)); + if let Some(at) = at { + let entry = &column[at]; + let up_to = match at.checked_sub(1).map(|i| &column[i]) { + Some( + header @ Placed { + line: Line::Header { .. }, + .. + }, + ) => header.top, + _ => entry.top, + }; + let bottom = entry.top + entry.height; + if up_to < self.top { + self.top = up_to; + } else if bottom > self.top + height { + self.top = bottom - height; + } } } self.top = self.top.min(self.max); self.top } - /// A notch of the wheel: `delta` lines, held at the column's ends. False + /// A notch of the wheel: `delta` rows, held at the column's ends. False /// when the column fits, or is already at that end, and nothing moved. pub fn wheel(&mut self, delta: isize) -> bool { let next = self.top.saturating_add_signed(delta).min(self.max); @@ -455,11 +864,13 @@ mod tests { Rect::new(0, 0, width, 30) } + const OPEN: [Fold; 3] = [Fold::Open; 3]; + /// A body with room for all three at their widths and the pane gets /// every column left over. #[test] fn the_columns_open_at_their_widths_and_the_pane_takes_the_rest() { - let c = columns(body(190), None); + let c = columns(body(190), None, OPEN); assert_eq!( [c.projects.width, c.worktrees.width, c.sessions.width], WIDTHS @@ -474,35 +885,81 @@ mod tests { /// pane keeps its own. #[test] fn a_narrow_body_squeezes_the_columns_for_the_pane() { - let c = columns(body(80), None); + let c = columns(body(80), None, OPEN); assert!(c.pane.width >= MIN_PANE_W, "{c:?}"); for w in [c.projects.width, c.worktrees.width, c.sessions.width] { assert!((MIN_W..32).contains(&w), "{c:?}"); } // Widths dragged wide on a bigger window squeeze the same way. - let c = columns(body(80), Some([60, 60, 60])); + let c = columns(body(80), Some([60, 60, 60]), OPEN); assert!(c.pane.width >= MIN_PANE_W, "{c:?}"); assert!(c.widths().iter().all(|w| *w >= MIN_W), "{c:?}"); } + #[test] + fn uneven_saved_widths_fit_without_overspending_the_pane_floor() { + let c = columns(body(80), Some([10, 10, 100]), OPEN); + assert_eq!(c.widths(), [10, 10, 40]); + assert_eq!(c.pane.width, MIN_PANE_W); + } + + #[test] + fn folded_rails_and_saved_open_widths_share_only_the_open_budget() { + let c = columns( + body(62), + Some([400, 10, 100]), + [Fold::Rail, Fold::Open, Fold::Open], + ); + assert_eq!(c.widths(), [RAIL_W, 10, 31]); + assert_eq!(c.pane.width, MIN_PANE_W); + } + + #[test] + fn widths_fit_tiny_bodies_and_keep_the_body_origin() { + let tiny = columns(Rect::new(7, 4, 19, 8), Some([u16::MAX; 3]), OPEN); + assert_eq!(tiny.projects.x, 7); + assert_eq!(tiny.widths().iter().map(|w| u32::from(*w)).sum::(), 19); + assert_eq!(tiny.pane.width, 0); + + let below_floor = columns(Rect::new(5, 2, 40, 8), None, OPEN); + assert_eq!(below_floor.widths(), [10; 3]); + assert_eq!(below_floor.pane.width, 10); + + let at_floor = columns(Rect::new(5, 2, 50, 8), None, OPEN); + assert_eq!(at_floor.widths(), [10; 3]); + assert_eq!(at_floor.pane.width, MIN_PANE_W); + + let above_floor = columns(Rect::new(5, 2, 79, 8), Some([10, 10, 100]), OPEN); + assert_eq!( + above_floor + .widths() + .iter() + .map(|w| u32::from(*w)) + .sum::(), + 59 + ); + assert_eq!(above_floor.pane.width, MIN_PANE_W); + assert_eq!(above_floor.projects.x, 5); + } + /// Widths a drag left are the ones laid out, and each BORDER sits /// just past its column's rule. #[test] fn the_columns_open_at_the_widths_they_were_dragged_to() { - let c = columns(body(190), Some([30, 15, 40])); + let c = columns(body(190), Some([30, 15, 40]), OPEN); assert_eq!(c.widths(), [30, 15, 40]); assert_eq!(c.pane.x, 85); assert_eq!([c.border(0), c.border(1), c.border(2)], [30, 45, 85]); - assert_eq!(c.grab_zone(2), Rect::new(84, 0, 2, 30)); + assert_eq!(c.grab_zone(2), Some(Rect::new(84, 0, 2, 30))); } /// A drag moves the one column's BORDER, wider or narrower, and the /// other two keep their widths. #[test] fn a_drag_widens_and_narrows_the_one_column() { - let wide = drag_border(body(190), None, 1, 50); + let wide = drag_border(body(190), None, OPEN, 1, 50); assert_eq!(wide, Some([20, 30, 32])); - let narrow = drag_border(body(190), wide, 2, 70); + let narrow = drag_border(body(190), wide, OPEN, 2, 70); assert_eq!(narrow, Some([20, 30, 20])); } @@ -510,8 +967,11 @@ mod tests { /// off the right it stops where the pane keeps its own. #[test] fn a_drag_holds_the_column_and_the_pane_to_their_floors() { - assert_eq!(drag_border(body(190), None, 0, -40), Some([MIN_W, 22, 32])); - let [p, w, s] = drag_border(body(190), None, 1, 500).unwrap(); + assert_eq!( + drag_border(body(190), None, OPEN, 0, -40), + Some([MIN_W, 22, 32]) + ); + let [p, w, s] = drag_border(body(190), None, OPEN, 1, 500).unwrap(); assert_eq!((p, s), (20, 32), "only the dragged column moved"); assert_eq!(190 - (p + w + s), MIN_PANE_W); } @@ -520,21 +980,105 @@ mod tests { /// widths stay as they were remembered. #[test] fn a_body_with_no_room_remembers_no_drag() { - assert_eq!(drag_border(body(40), None, 0, 30), None); + assert_eq!(drag_border(body(40), None, OPEN, 0, 30), None); } - fn rows(n: usize) -> Vec { - (0..n).map(|i| Line::Row(Row::Session(i))).collect() + /// A folded column takes its rail's one cell and the pane the rest; + /// its width is the frame's, and the open columns keep theirs. + #[test] + fn a_folded_column_is_a_rail_and_the_pane_takes_its_width() { + let c = columns( + body(190), + Some([30, 15, 40]), + [Fold::Rail, Fold::Open, Fold::Rule], + ); + assert_eq!(c.widths(), [RAIL_W, 15, RAIL_W]); + assert_eq!(c.pane.x, 2 * RAIL_W + 15); + assert_eq!(c.grab_zone(0), None, "a rail has no width to drag"); + assert_eq!( + c.grab_zone(1), + Some(Rect::new(c.border(1) - 1, 0, 1, 30)), + "beside a fold the zone stops at the rule" + ); + // Only the open columns are squeezed on a narrow body. + let c = columns(body(60), None, [Fold::Rail, Fold::Rail, Fold::Open]); + assert_eq!(c.widths(), [RAIL_W, RAIL_W, 32]); + } + + /// A drag beside a folded column keeps the width the fold will open + /// back up to, not the rail's one cell. + #[test] + fn a_drag_keeps_a_folded_columns_width() { + let folds = [Fold::Rail, Fold::Open, Fold::Open]; + let widths = drag_border(body(190), Some([30, 15, 40]), folds, 1, 21); + assert_eq!(widths, Some([30, 20, 40])); + assert_eq!(drag_border(body(190), None, folds, 0, 40), None); + } + + /// `n` session PILLS, one under the other. + fn pills(n: usize) -> Column { + let mut stack = Stack::default(); + for i in 0..n { + stack.pill(Row::Session(i)); + } + stack.column } - /// The window follows the cursor down and is back at the top for a - /// cursor that fits there. + /// Pills stack two rows apart, sharing their pads, and a header after + /// them sits straight under the last one's bottom pad. + #[test] + fn pills_stack_on_their_stride() { + let mut stack = Stack::default(); + stack.pill(Row::Session(0)); + stack.pill(Row::Session(1)); + stack.header("TERMINALS".into(), None); + stack.pill(Row::Session(2)); + let tops: Vec = stack.column.iter().map(|p| p.top).collect(); + assert_eq!(tops, [0, 2, 5, 6]); + assert_eq!(content_height(&stack.column), 9); + } + + /// A pill with rows of its own under its text — RECENT PROMPTS, the + /// FOLLOW-UP COMPOSER — grows by them and keeps its bottom pad, so the + /// next one starts below it rather than sharing it. + #[test] + fn a_pill_with_rows_inside_keeps_its_bottom_pad() { + let mut stack = Stack::default(); + stack.pill(Row::Session(0)); + stack.pill_with(Row::Session(1), 3); + stack.pill(Row::Session(2)); + let at: Vec<(usize, usize)> = stack.column.iter().map(|p| (p.top, p.height)).collect(); + assert_eq!(at, [(0, 3), (2, 6), (8, 3)]); + } + + /// The window follows the cursor down only as far as its pill needs, + /// and back up to a cursor above it. #[test] fn the_scroll_keeps_the_cursor_on_screen() { - let lines = rows(20); - assert_eq!(scroll_to(&lines, Some(Row::Session(3)), 5), 0); - assert_eq!(scroll_to(&lines, Some(Row::Session(12)), 5), 8); - assert_eq!(scroll_to(&lines, None, 5), 0, "no cursor, no scroll"); + let column = pills(20); + let mut scroll = ColumnScroll::default(); + assert_eq!(scroll.settle(&column, Some(Row::Session(3)), 10), 0); + // Pill 12 runs rows 24..27: the window ends on its bottom pad. + assert_eq!(scroll.settle(&column, Some(Row::Session(12)), 10), 17); + assert_eq!(scroll.settle(&column, Some(Row::Session(2)), 10), 4); + assert_eq!(scroll.max, 41 - 10); + let mut fresh = ColumnScroll::default(); + assert_eq!(fresh.settle(&column, None, 10), 0, "no cursor, no scroll"); + } + + /// Scrolled back up to the first row of a group, the header over it + /// comes along. + #[test] + fn the_scroll_brings_the_header_along() { + let mut stack = Stack::default(); + stack.header("TERMINALS".into(), None); + stack.pill(Row::Session(0)); + stack.pill(Row::Session(1)); + let mut scroll = ColumnScroll { + top: 3, + ..Default::default() + }; + assert_eq!(scroll.settle(&stack.column, Some(Row::Session(0)), 2), 0); } #[test] diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index 10e216ee..0ed0e4bd 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -186,6 +186,9 @@ const HELP_KEY_W: usize = 14; enum HelpKeys { Lit(&'static str), Act(&'static [crate::keymap::Action]), + /// The actions' chords, then fixed keys beside them — the PANELS' + /// own (`panels::PANEL_KEYS`), which no keymap row carries. + Also(&'static [crate::keymap::Action], &'static str), } type HelpSection = (&'static str, &'static [(HelpKeys, &'static str)]); const SETTINGS_W: u16 = 84; @@ -967,7 +970,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // are for keys that belong to an overlay rather than the // grid, which is why they aren't rebindable. use crate::keymap::Action::*; - use HelpKeys::{Act, Lit}; + use HelpKeys::{Act, Also, Lit}; const LEFT: &[HelpSection] = &[ ( "NAVIGATE & SEARCH", @@ -1093,6 +1096,14 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { let keys_of = |k: &HelpKeys| -> String { match k { Lit(s) => (*s).to_string(), + Also(actions, more) => { + let shown = actions + .iter() + .map(|a| app.keymap.shown_label(*a)) + .collect::>() + .join(" / "); + format!("{shown} {more}") + } Act(actions) => { let full = actions .iter() @@ -3818,10 +3829,14 @@ fn editor_name(cmd: &str) -> &str { } /// The bottom bar, drawn under the splash and the collapsed view too, -/// with the KEY COMBO DISPLAY on the padding row above it. +/// with the KEY COMBO DISPLAY on the padding row above it — except beside +/// the PANELS, whose padding row stays the breathing space it always was +/// there. fn draw_footer(f: &mut Frame, app: &mut App, area: Rect) { draw_footer_bar(f, app, area); - draw_key_combo(f, app, area); + if !app.panels_active() { + draw_key_combo(f, app, area); + } } /// The KEY COMBO DISPLAY: the last key press @@ -4114,6 +4129,8 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { // overlay is: these are the first place a rebound key would start // lying. let k = |a| key_hint(app, a); + // The PANELS' `m` opens the cursor row's menu (`panels_view::menu_hint`). + let menu = panels_view::menu_hint(app); let text = match app.focus { // The pane is the CLOUD SESSION PANEL: there is no terminal to // type into, and Enter hands the session to the browser. @@ -4164,7 +4181,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { ), Focus::Terminal => "select a session and press Enter to attach".to_string(), Focus::Projects => format!( - "{}/{}: add {}: rename {}: remove {}: search {}: help", + "{}/{}: add {}: rename {}: remove {}: search {menu}{}: help", k(Action::New), k(Action::AddProject), k(Action::Rename), @@ -4175,7 +4192,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { // An open-PR row answers to a different set of verbs than a // checkout does, so the hint follows the cursor into the group. Focus::Worktrees if app.selected_worktree_pr().is_some() => format!( - "{}: new session {}: preset {}: open in browser {}: diff PgUp/PgDn: scroll {}: refresh {}: search {}: help", + "{}: new session {}: preset {}: open in browser {}: diff PgUp/PgDn: scroll {}: refresh {}: search {menu}{}: help", k(Action::New), k(Action::AgentPresets), k(Action::Activate), @@ -4187,7 +4204,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { // An issue row: the browser, a prompt or a preset on it, and // the pane's scroll keys. Focus::Worktrees if app.selected_worktree_issue().is_some() => format!( - "{}: open in browser {}: prompt {}: preset PgUp/PgDn: scroll {}: search {}: help", + "{}: open in browser {}: prompt {}: preset PgUp/PgDn: scroll {}: search {menu}{}: help", k(Action::Activate), k(Action::QuickPrompt), k(Action::AgentPresets), @@ -4195,7 +4212,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { k(Action::Help) ), Focus::Worktrees => format!( - "{}: new worktree {}: presets {}: {} {}: open {}: terminal {}: delete {}: refresh PRs {}: search {}: help", + "{}: new worktree {}: presets {}: {} {}: open {}: terminal {}: delete {}: refresh PRs {}: search {menu}{}: help", k(Action::New), k(Action::AgentPresets), k(Action::Rename), @@ -4224,7 +4241,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { .is_some_and(|row| row.id().is_none()) => { format!( - "{}: open in browser {}: diff PgUp/PgDn: scroll {}: refresh {}: help", + "{}: open in browser {}: diff PgUp/PgDn: scroll {}: refresh {menu}{}: help", k(Action::Activate), k(Action::GitDiff), k(Action::RefreshPullRequests), @@ -4232,7 +4249,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { ) } Focus::Sessions if app.selected_link().is_some() => format!( - "{}: open in browser {}: edit URL {}: delete {}: help", + "{}: open in browser {}: edit URL {}: delete {menu}{}: help", k(Action::Activate), k(Action::Rename), k(Action::Delete), @@ -4241,7 +4258,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { // A Cloud row leads out of nebula like a link row does; the // menu holds the one verb that reaches the session from here. Focus::Sessions if app.previewed_cloud().is_some() => format!( - "{}: open in browser {}: rename {}: archive {}: del {}: help", + "{}: open in browser {}: rename {}: archive {}: del {menu}{}: help", k(Action::Activate), k(Action::Rename), k(Action::Archive), @@ -4249,7 +4266,7 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { k(Action::Help) ), Focus::Sessions => format!( - "{}: focus {}: agent {}: presets {}: terminal {}: rename {}: archive {}: del {}: help", + "{}: focus {}: agent {}: presets {}: terminal {}: rename {}: archive {}: del {menu}{}: help", k(Action::Activate), k(Action::New), k(Action::AgentPresets), @@ -4260,6 +4277,12 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { k(Action::Help) ), }; + // A PANELS column folded to its RAIL leads the hints with the key + // that opens it again (`panels_view::restore_hints`). + let text = match panels_view::restore_hints(app) { + Some(restore) if !app.term_locked => format!("{restore} {text}"), + _ => text, + }; Span::styled(text, Style::default().fg(th.dim)) }; // Quiet footer: context on the left, live stats on the right. The @@ -4282,8 +4305,19 @@ fn draw_footer_bar(f: &mut Frame, app: &mut App, area: Rect) { let crumbs = breadcrumb(app); if !crumbs.is_empty() { spans.extend(crumbs); - // No changed-file count here: it rides each card's branch, where - // it reads as that checkout's (`launcher_view::draw_card`). + // No changed-file count here on the GRID: it rides each card's + // branch, where it reads as that checkout's + // (`launcher_view::draw_card`). The PANELS have no card to carry + // it, so the selected checkout's count rides the breadcrumb, as + // it always did there. + if app.panels_active() { + if let Some(n) = app.selected_worktree_changes().filter(|n| *n > 0) { + spans.push(Span::styled( + format!(" +{n} file{}", if n == 1 { "" } else { "s" }), + Style::default().fg(th.warn), + )); + } + } spans.push(Span::styled(" ", Style::default())); } let mut hints = hints; diff --git a/crates/nebula-tui/src/ui/panels_view.rs b/crates/nebula-tui/src/ui/panels_view.rs index 7a2ab6bd..e772e149 100644 --- a/crates/nebula-tui/src/ui/panels_view.rs +++ b/crates/nebula-tui/src/ui/panels_view.rs @@ -1,27 +1,33 @@ //! The PANELS' drawing (`crate::panels` is their model, //! `event_loop::panels` their keys): PROJECTS | WORKTREES | SESSIONS down -//! the left of the body, each column a header over a list of rows — a +//! the left of the body, each column a header over a list — PROJECTS as +//! 3-row buttons, WORKTREES and SESSIONS as 2-row PILLS, each with a //! STATUS DOT, the name, how long since it moved and what it runs on — -//! with the cursor's row on the raised selection bar, and the PANE beside -//! them reading the session under the SESSIONS cursor (`draw_terminal`, -//! the very pane the GRID's full-screen session is drawn into). The -//! focused column wears the FOCUS TINT, as the pane does when it has the -//! keys. +//! the cursor's row raised on the selection fill with its rail in the +//! row's own color, and the PANE beside them reading the session under +//! the SESSIONS cursor (`draw_terminal`, the very pane the GRID's +//! full-screen session is drawn into). The focused column wears the FOCUS +//! TINT, as the pane does when it has the keys. +//! +//! A session's pill carries its RECENT PROMPTS under its text and, once +//! Space or its FOLLOW-UP CHEVRON expands it, the FOLLOW-UP COMPOSER under +//! those: the pill grows around them, and the pills below move down. //! //! Every row registers a `HitTarget::PanelsRow` ahead of its column's //! `PanelBg`, so a click lands on the row and a click on the air under the -//! rows only takes FOCUS. Each column's BORDER — its rule and the cell +//! rows only takes FOCUS; a session's chevron and its open composer +//! register ahead of their row. Each column's BORDER — its rule and the cell //! after it — registers a `HitTarget::PanelsBorder` ahead of them all, and //! wears a grip that lights while it is hovered or dragged. use super::{ - ago_badge, draw_focus_tint, draw_terminal, fit_ago, key_hint, render_button, row_rect, - status_color, status_dot, status_name_spans, sweep_ramp, truncate, HelpSection, - PENDING_SESSION_BADGE, + ago_badge, draw_focus_tint, draw_terminal, fit_ago, key_hint, multiline_input_lines, + open_counts_badge, render_button, row_bar, row_rect, selection_mark, status_color, status_dot, + status_name_spans, sweep_ramp, truncate, HelpSection, MIN_NAME_W, PENDING_SESSION_BADGE, }; use crate::app::{App, Focus, HitTarget, SessionRow, WorktreeRow}; use crate::keymap::Action; -use crate::panels::{Line as PanelLine, Row}; +use crate::panels::{Fold, Line as PanelLine, Placed, Row, PILL_CELL, PILL_H, PROJECT_BTN_H}; use crate::theme::Theme; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -38,27 +44,68 @@ const PENDING_WORKTREE_BADGE: &str = " creating"; /// The ROOT WORKTREE's badge, and the glyph alone on a narrow column. const ROOT_BADGE: &str = " ⌂ root"; const ROOT_GLYPH: &str = " ⌂"; -/// The RUNNING badge of a checkout whose RUN COMMAND is up (`r`). -const RUN_BADGE: &str = " ▶"; +/// The RUNNING badge of a checkout whose RUN COMMAND is up (`r`), and the +/// bare glyph where the word would cut the branch. The glyph never +/// yields: it is the one thing on the row that says a process is serving +/// from this checkout. +const RUN_BADGE: &str = " ▶ running"; +const RUN_GLYPH: &str = " ▶"; /// What hangs a checkout under the pull request on its head branch. const NESTED_INDENT: &str = "└"; +/// A selected PILL's pads, top and bottom: half blocks in the fill's +/// color, so the fill reads as a rounded slab about two rows tall. +const PILL_HALF: (char, char) = ('▄', '▀'); +/// The selection rail owns the pill's first column outright: a solid `█` +/// on the text row, the pad's own `PILL_HALF` glyph on the pads. A +/// half-width `▌` can't run the pill's full height — a cell holds one +/// glyph and two colors, so a quadrant cap on a pad row strands the fill +/// quarter beside it on bare background, which the FOCUS TINT turns into +/// a dark notch at each of the pill's left corners. +const PILL_RAIL: &str = "█"; /// Rows a BORDER's grip runs down the middle of its rule: the LAUNCHER /// VIEW's pane grip beside the cards is as tall. const GRIP_H: u16 = 4; +/// Narrowest a column's title row carries its fold chevron at: any +/// narrower and the `◀` would sit on the title. +const FOLD_MIN_W: u16 = 16; +/// A session pill's FOLLOW-UP CHEVRON, at the end of its text row: folded, +/// and expanded into its FOLLOW-UP COMPOSER. +const FOLLOW_UP_FOLDED: &str = " ▸"; +const FOLLOW_UP_OPEN: &str = " ▾"; +/// What a RECENT PROMPTS line opens with, after the pill's rail column: a +/// column to land under the name (past the STATUS DOT), and a bullet so +/// the lines read as a list hanging off the row rather than as more rows. +const PROMPT_INDENT: &str = " · "; -/// The PANELS over `body`: the three columns and the pane beside them. +/// The PANELS over `body`: the three columns and the pane beside them, a +/// folded column drawn as its RAIL or its bare RULE (`panels::Fold`). pub(super) fn draw(f: &mut Frame, app: &mut App, body: Rect) { app.body_area = body; - let cols = crate::panels::columns(body, app.panels_widths); + // A FOCUS the cursor's own move has folded SESSIONS out from under — + // onto a pull request, say — steps off it before the columns are laid + // out, so the tint and the keys land on an open column. + crate::panels::settle_focus(app); + let cols = crate::panels::layout(app, body); // The BORDERS first, so they win `hit_at`'s first-match scan against // the row, or the pane, a grab cell lands on. for i in 0..3 { - app.hits - .push((cols.grab_zone(i), HitTarget::PanelsBorder(i))); + if let Some(zone) = cols.grab_zone(i) { + app.hits.push((zone, HitTarget::PanelsBorder(i))); + } + } + let [projects, worktrees, sessions] = cols.folds; + match projects { + Fold::Open => draw_projects(f, app, cols.projects), + fold => draw_fold(f, app, cols.projects, Focus::Projects, fold), + } + match worktrees { + Fold::Open => draw_worktrees(f, app, cols.worktrees), + fold => draw_fold(f, app, cols.worktrees, Focus::Worktrees, fold), + } + match sessions { + Fold::Open => draw_sessions(f, app, cols.sessions), + fold => draw_fold(f, app, cols.sessions, Focus::Sessions, fold), } - draw_projects(f, app, cols.projects); - draw_worktrees(f, app, cols.worktrees); - draw_sessions(f, app, cols.sessions); draw_terminal(f, app, cols.pane); // The FOCUS TINT on the column with the keys — inside its rule, so the // rule stays the boundary between two columns rather than part of one. @@ -91,7 +138,8 @@ fn draw_grips( return; // no room for the grip and rule either side of it } let top = body.y + (body.height - GRIP_H) / 2; - for i in 0..3 { + // A folded column has no BORDER to drag, and so no grip. + for i in (0..3).filter(|i| cols.folds[*i] == Fold::Open) { let x = cols.border(i).saturating_sub(1); let active = app.panels_drag.map(|(at, _)| at) == Some(i) || app.hover_panels_border == Some(i); @@ -105,10 +153,51 @@ fn draw_grips( } } +/// A folded column: its rule alone, and for a RAIL the `▶` over it on +/// the title row, the whole strip a click that opens the column again +/// at the width it was left at. The bare RULE SESSIONS folds to of its own +/// accord has no chevron and no target: it opens the same way it folded, +/// and a click there would only write a fold the user never chose. +fn draw_fold(f: &mut Frame, app: &mut App, area: Rect, focus: Focus, fold: Fold) { + let th = app.theme; + f.render_widget( + Block::default() + .borders(Borders::RIGHT) + .border_style(Style::default().fg(th.edge)), + area, + ); + if fold != Fold::Rail { + return; + } + if area.height > 1 { + f.render_widget( + Paragraph::new(Span::styled("▶", Style::default().fg(th.dim))), + Rect { + y: area.y + 1, + width: 1, + height: 1, + ..area + }, + ); + } + app.hits.push((area, HitTarget::PanelsFold(focus))); +} + /// A column's frame: its rule down the right, a blank row, the title with -/// its count, a blank row. Returns the rect its list fills, one column -/// short of the rule so a row's text never touches it. -fn column(f: &mut Frame, area: Rect, title: &str, count: usize, focused: bool, th: Theme) -> Rect { +/// its count and, at the row's right end, the `◀` that folds the column +/// to its RAIL, then a blank row. Returns the rect its list fills, one +/// column short of the rule so a row's text never touches it. +#[allow(clippy::too_many_arguments)] +fn column( + f: &mut Frame, + area: Rect, + title: &str, + count: usize, + focused: bool, + th: Theme, + focus: Focus, + hits: &mut Vec<(Rect, HitTarget)>, +) -> Rect { let block = Block::default() .borders(Borders::RIGHT) .border_style(Style::default().fg(th.edge)); @@ -128,6 +217,20 @@ fn column(f: &mut Frame, area: Rect, title: &str, count: usize, focused: bool, t )); } f.render_widget(Paragraph::new(Line::from(spans)), r); + // A title row too narrow for it keeps the title alone: the + // chevron would land on its text. + if r.width >= FOLD_MIN_W { + let glyph = Rect { + x: r.x + r.width - 3, + width: 2, + ..r + }; + f.render_widget( + Paragraph::new(Span::styled("◀", Style::default().fg(th.dim))), + glyph, + ); + hits.push((glyph, HitTarget::PanelsFold(focus))); + } } Rect { y: inner.y + 3, @@ -137,11 +240,43 @@ fn column(f: &mut Frame, area: Rect, title: &str, count: usize, focused: bool, t } } -/// A column's list: the `lines` from where the column is scrolled to -/// (`panels::ColumnScroll`, which brings the `cursor`'s row back on screen -/// when it moves), each row drawn by `row` as its spans -/// and the color its selection rail takes. Registers each row, and each -/// header that folds, as a `PanelsRow`, then the whole column as its +/// An empty column's one-line nudge: accent keys and dim prose +/// alternating, the first key in the row gutter so it lines up with row +/// text. Every key is the live keymap's chord for its action. +fn hint(f: &mut Frame, app: &App, list: Rect, pairs: &[(Action, &str)]) { + let th = app.theme; + let mut spans = vec![Span::raw(ROW_GUTTER)]; + for (action, prose) in pairs { + spans.push(Span::styled( + key_hint(app, *action), + Style::default().fg(th.accent), + )); + spans.push(Span::styled(*prose, Style::default().fg(th.dim))); + } + f.render_widget(Paragraph::new(Line::from(spans)), list); +} + +/// How a column's rows are drawn: PROJECTS as buttons, the name on the +/// middle of their rows; WORKTREES and SESSIONS as PILLS. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Shape { + Button, + Pill, +} + +/// What a column's `row` closure draws one row as: its lines of spans — a +/// pill takes the first, a button one per text row — the color its +/// selection rail takes, and whether the first line's last span is a +/// session's FOLLOW-UP CHEVRON, a click target of its own. +type RowText = (Vec>>, Color, bool); + +/// A column's list: `column`'s entries from the row it is scrolled to +/// (`panels::ColumnScroll`, which brings the `reveal` row back on screen +/// when it moves — the `cursor`'s, or the pill whose FOLLOW-UP COMPOSER +/// has the keys), each row drawn by `row` ([`RowText`]), a session pill's +/// RECENT PROMPTS and composer under it ([`draw_pill_inside`]). Registers +/// each row, and each header that folds, as a `PanelsRow` — a session's +/// chevron and open composer ahead of it — then the whole column as its /// `PanelBg`. #[allow(clippy::too_many_arguments)] fn draw_list( @@ -149,22 +284,30 @@ fn draw_list( app: &mut App, area: Rect, list: Rect, - lines: &[PanelLine], + column: &[Placed], cursor: Option, + reveal: Option, focus: Focus, - mut row: impl FnMut(&App, Row, usize) -> (Vec>, Color), + shape: Shape, + mut row: impl FnMut(&App, Row, usize) -> RowText, ) { let th = app.theme; let focused = app.focus == focus; let height = usize::from(list.height); - let top = app.panels_scroll[crate::panels::scroll_slot(focus)].settle(lines, cursor, height); - for (y, line) in lines.iter().skip(top).take(height).enumerate() { - let Some(r) = row_rect(list, y) else { + let top = app.panels_scroll[crate::panels::scroll_slot(focus)].settle(column, reveal, height); + let width = usize::from(list.width); + // SESSIONS' rows, read once a pill has something hanging in it. + let mut sessions: Option> = None; + for (at, placed) in column.iter().enumerate() { + let y = placed.top as isize - top as isize; + if y >= height as isize { break; - }; - match line { - PanelLine::Blank => {} + } + match &placed.line { PanelLine::Header { text, fold } => { + let Some(r) = rows_at(list, y, 1) else { + continue; + }; f.render_widget( Paragraph::new(Span::styled( format!(" {text}"), @@ -176,60 +319,453 @@ fn draw_list( app.hits.push((r, HitTarget::PanelsRow(*fold))); } } - PanelLine::Row(at) => { - let (spans, mark) = row(app, *at, usize::from(r.width)); - let selected = Some(*at) == cursor; - render_button(f, r, vec![spans], selected, focused, th, 0, mark); - app.hits.push((r, HitTarget::PanelsRow(*at))); + PanelLine::Row(at_row) => { + if y + placed.height as isize <= 0 { + continue; + } + let (text, mark, chevron) = row(app, *at_row, width); + let selected = Some(*at_row) == cursor; + let hit = match shape { + Shape::Button => { + let Some(r) = rows_at(list, y, placed.height) else { + continue; + }; + // A button scrolled part-way off the top keeps the + // rows still on screen: its text row moves up with + // it, and lines of text above the edge go. + let skip = (-y).max(0) as usize; + let middle = PROJECT_BTN_H / 2; + let (text, text_row) = if skip <= middle { + (text, middle - skip) + } else { + (text.into_iter().skip(skip - middle).collect(), 0) + }; + render_button(f, r, text, selected, focused, th, text_row as u16, mark); + Some(r) + } + Shape::Pill => { + let spans = text.into_iter().next().unwrap_or_default(); + // The chevron's cells: past the rail column, after + // every span ahead of it. + let chevron = + spans + .split_last() + .filter(|_| chevron) + .map(|(glyph, ahead)| { + let x = 1 + ahead.iter().map(Span::width).sum::(); + (x, glyph.width()) + }); + let inside = placed.height.saturating_sub(PILL_CELL); + render_pill(f, list, y, spans, selected, focused, th, mark, inside); + if let Row::Session(i) = *at_row { + let bar = selected.then(|| pill_bar(focused, mark, th)); + if inside > 0 { + let rows = + sessions.get_or_insert_with(|| app.visible_session_rows()); + draw_pill_inside(f, app, list, y, rows.get(i), inside, bar); + } + if let (Some((x, w)), Some(r)) = (chevron, rows_at(list, y + 1, 1)) { + let x = (x as u16).min(r.width); + let cell = Rect { + x: r.x + x, + width: (w as u16).min(r.width - x), + ..r + }; + app.hits + .push((cell, HitTarget::PanelsRow(Row::FollowUp(i)))); + } + } + // The pad two pills share is the lower one's: its + // fill owns that cell's bottom half. A pill with + // nothing stacked onto it — or rows of its own + // under its text — keeps its bottom pad. + let next = column.get(at + 1).map(|p| p.top); + let cell = placed.height; + let rows = next.map_or(cell, |n| n.saturating_sub(placed.top).min(cell)); + rows_at(list, y, rows) + } + }; + if let Some(r) = hit { + app.hits.push((r, HitTarget::PanelsRow(*at_row))); + } } } } app.hits.push((area, HitTarget::PanelBg(focus))); } -/// The `?` overlay's two columns while the PANELS are up: the keys the -/// columns answer to, in place of the grid's cards and PROJECT TABS. Every -/// chord is the live keymap's, as the grid's help is; the rows that read -/// the selection — checkouts, GitHub, sessions — are the grid's own. +/// `rows` rows of `list` from row `y`, which may sit above the list's top +/// once it has scrolled: clipped to what is still on screen, None when +/// none of it is. +fn rows_at(list: Rect, y: isize, rows: usize) -> Option { + let visible = rows as isize + y.min(0); + if visible <= 0 || y >= list.height as isize { + return None; + } + let y = list.y + y.max(0) as u16; + Some(Rect { + y, + height: (visible as u16).min(list.bottom() - y), + ..list + }) +} + +/// The fill and rail of a selected PILL, `(fill, rail)`: in the focused +/// column the raised `sel_bg` with the row's `mark` on the rail (see +/// `selection_mark`); elsewhere the barely-raised `sel_bg_dim` under a +/// dim rail, so an unfocused cursor reads as a place, not a signal. +fn pill_bar(focused: bool, mark: Color, th: Theme) -> (Color, Color) { + if focused { + (th.sel_bg, selection_mark(mark, th)) + } else { + (th.sel_bg_dim, th.dim) + } +} + +/// One PILL from row `top` of `list`: half-block pad, `spans`, `inside` +/// rows left for what hangs under the text (a session's RECENT PROMPTS and +/// FOLLOW-UP COMPOSER, [`draw_pill_inside`]), half-block pad — the pads +/// drawn only under the cursor, running the full width so the fill has no +/// dark notch beside the STATUS DOT, the rail column carrying the pad's own +/// half block in the rail's color so the rail spans the pill's whole +/// height. Dim spans are lifted to muted on the fill, as `render_button` +/// lifts them. +#[allow(clippy::too_many_arguments)] +fn render_pill( + f: &mut Frame, + list: Rect, + top: isize, + mut spans: Vec, + selected: bool, + focused: bool, + th: Theme, + mark: Color, + inside: usize, +) { + let (fill, rail) = pill_bar(focused, mark, th); + if selected { + let mut pad = |glyph: char, row: isize| { + if let Some(r) = rows_at(list, row, 1) { + f.render_widget( + Paragraph::new(Span::styled( + glyph.to_string().repeat(usize::from(list.width)), + Style::default().fg(fill), + )), + r, + ); + // Same half block, rail-colored: the rail's cap and the + // fill quarter beside it are one cell, so they have to be + // one color, and the rail is the one worth keeping. + f.render_widget( + Paragraph::new(Span::styled(glyph.to_string(), Style::default().fg(rail))), + Rect { width: 1, ..r }, + ); + } + }; + pad(PILL_HALF.0, top); + pad(PILL_HALF.1, top + (PILL_H + inside) as isize); + } + let Some(text_row) = rows_at(list, top + 1, 1) else { + return; + }; + let marker = if selected { + for s in &mut spans { + if s.style.fg == Some(th.dim) { + s.style.fg = Some(th.muted); + } + } + Span::styled(PILL_RAIL, Style::default().fg(rail)) + } else { + Span::raw(" ") + }; + spans.insert(0, marker); + f.render_widget( + Paragraph::new(Line::from(spans)).style(row_bar(selected, focused, th)), + text_row, + ); +} + +/// What hangs inside session `row`'s pill from row `top` of `list`, +/// in the `inside` rows it grew by: its RECENT PROMPTS straight under its +/// text, then — on the pill that is expanded — its FOLLOW-UP COMPOSER, +/// which registers ahead of the row so a click in what is being typed +/// never reads as a second click on the pill. `bar` is the pill's +/// `(fill, rail)` from [`pill_bar`] when it is the cursor's: the lines then +/// sit on its fill and carry the rail down their first column, so the +/// pill and what hangs in it are one slab. +fn draw_pill_inside( + f: &mut Frame, + app: &mut App, + list: Rect, + top: isize, + row: Option<&SessionRow>, + inside: usize, + bar: Option<(Color, Color)>, +) { + let th = app.theme; + let Some(row @ SessionRow::Agent(a)) = row else { + return; + }; + let prompts = crate::panels::prompt_lines(app, row); + let follow_up = inside.saturating_sub(prompts); + let first_row = top + PILL_H as isize; + if prompts > 0 { + draw_prompt_lines(f, list, first_row, &a.recent_prompts, prompts, bar, th); + } + if follow_up > 0 { + let first_row = first_row + prompts as isize; + draw_follow_up_box(f, app, list, first_row, follow_up, bar, th); + if let Some(r) = rows_at(list, first_row, follow_up) { + app.hits.push((r, HitTarget::PanelsRow(Row::FollowUpBox))); + } + } +} + +/// The RECENT PROMPTS under a session's name, from `first_row`: the +/// newest `count` of `prompts`, oldest first so the bottom line is the +/// latest thing asked, each clipped to fit with its ago label pinned +/// right. Dim, with the newest lifted to muted so the eye lands on it — +/// these are context for the row, not rows of their own. On the cursor's +/// pill they take `bar` as [`draw_pill_inside`] says, dim lifting to muted +/// on the fill the way the pill's own dim spans do. +fn draw_prompt_lines( + f: &mut Frame, + list: Rect, + first_row: isize, + prompts: &[nebula_core::PromptEntry], + count: usize, + bar: Option<(Color, Color)>, + th: Theme, +) { + let skip = prompts.len().saturating_sub(count); + let free = usize::from(list.width).saturating_sub(1 + PROMPT_INDENT.chars().count()); + let lift = |color: Color| match bar { + Some(_) if color == th.dim => th.muted, + _ => color, + }; + let base = bar.map_or_else(Style::default, |(fill, _)| Style::default().bg(fill)); + let marker = match bar { + Some((_, rail)) => Span::styled(PILL_RAIL, Style::default().fg(rail)), + None => Span::raw(" "), + }; + for (i, entry) in prompts.iter().skip(skip).enumerate() { + let Some(area) = rows_at(list, first_row + i as isize, 1) else { + continue; + }; + let newest = skip + i + 1 == prompts.len(); + let text_color = lift(if newest { th.muted } else { th.dim }); + let (ago, text_max) = fit_ago(ago_badge(entry.submitted_at), free); + let text = truncate(&entry.text, text_max); + let mut spans = vec![ + marker.clone(), + Span::styled(PROMPT_INDENT, Style::default().fg(lift(th.dim))), + Span::styled(text.clone(), Style::default().fg(text_color)), + ]; + if !ago.is_empty() { + let gap = text_max.saturating_sub(text.chars().count()); + spans.push(Span::raw(" ".repeat(gap))); + spans.push(Span::styled(ago, Style::default().fg(lift(th.dim)))); + } + f.render_widget(Paragraph::new(Line::from(spans)).style(base), area); + } +} + +/// The FOLLOW-UP COMPOSER, `rows` tall from `first_row` inside its pill: a +/// framed box with `follow-up` on its top border, the turn being typed +/// inside it, and the keys that send it on the bottom one — drawn a row at +/// a time so a pill straddling the top of the column loses only the rows +/// that scrolled off. On the cursor's pill it takes `bar` as +/// [`draw_pill_inside`] says; the frame is the accent — this is where the +/// keyboard is pointed. +fn draw_follow_up_box( + f: &mut Frame, + app: &App, + list: Rect, + first_row: isize, + rows: usize, + bar: Option<(Color, Color)>, + th: Theme, +) { + let Some(follow_up) = &app.follow_up else { + return; + }; + let base = bar.map_or_else(Style::default, |(fill, _)| Style::default().bg(fill)); + let marker = match bar { + Some((_, rail)) => Span::styled(PILL_RAIL, Style::default().fg(rail)), + None => Span::raw(" "), + }; + let frame = Style::default().fg(th.accent); + // Everything but the rail column belongs to the box. + let box_w = usize::from(list.width).saturating_sub(1).max(2); + let text_w = crate::panels::follow_up_text_width(list.width); + let put = |f: &mut Frame, row: isize, mut spans: Vec>| { + if let Some(r) = rows_at(list, row, 1) { + spans.insert(0, marker.clone()); + f.render_widget(Paragraph::new(Line::from(spans)).style(base), r); + } + }; + + // Top border: ╭─ follow-up ──────╮ + let title = truncate(" follow-up ", box_w.saturating_sub(3)); + let fill = box_w.saturating_sub(3 + title.chars().count()); + put( + f, + first_row, + vec![Span::styled( + format!("╭─{title}{}╮", "─".repeat(fill)), + frame, + )], + ); + + // The text, windowed on the caret the way every other box windows it. + let visible = rows.saturating_sub(2).max(1); + let (lines, caret_row) = multiline_input_lines(&follow_up.input, text_w, th.accent, th); + let max_start = lines.len().saturating_sub(visible); + let start = caret_row.saturating_sub(visible / 2).min(max_start); + for i in 0..visible { + let mut spans = vec![Span::styled("│ ", frame)]; + let mut used = 0usize; + if let Some(line) = lines.get(start + i) { + for span in &line.spans { + used += span.content.chars().count(); + spans.push(Span::styled(span.content.to_string(), span.style)); + } + } + spans.push(Span::raw(" ".repeat(text_w.saturating_sub(used)))); + spans.push(Span::styled(" │", frame)); + put(f, first_row + 1 + i as isize, spans); + } + + // Bottom border, carrying the keys: ╰─ ↵ send · ^J nl · Esc ─╯ + let hint = follow_up_hint(box_w); + let fill = box_w.saturating_sub(3 + hint.chars().count()); + put( + f, + first_row + rows as isize - 1, + vec![Span::styled( + format!("╰─{hint}{}╯", "─".repeat(fill)), + frame, + )], + ); +} + +/// The keys on the composer's bottom border, widest that fits `width` (the +/// box's own, borders included). The column is narrow and a hint wider +/// than its border is silently chopped, so this steps down. +fn follow_up_hint(width: usize) -> &'static str { + if width >= 32 { + " ↵ send · ⇧↵ newline · Esc close " + } else if width >= 24 { + " ↵ send · ^J nl · Esc " + } else if width >= 14 { + " ↵ · ^J · Esc " + } else { + "" + } +} + +/// `⇧P: show projects ⇧B: show worktrees`: the key that opens each +/// column folded to its RAIL by hand, for the footer to lead its hints +/// with. None off the PANELS, and with no column folded — SESSIONS' +/// own RULE beside a pull request opens of itself and needs no key. +pub(super) fn restore_hints(app: &App) -> Option { + if !app.panels_active() { + return None; + } + let hints: Vec = ["projects", "worktrees", "sessions"] + .iter() + .zip(crate::panels::FOLD_KEYS) + .zip(app.panels_hidden) + .filter(|(_, hidden)| *hidden) + .map(|((column, key), _)| format!("{}: show {column}", key.display())) + .collect(); + (!hints.is_empty()).then(|| hints.join(" ")) +} + +/// `m: menu ` for the footer's hints on a PANELS column, before its +/// `?: help`, as they always ended there: `m` opens the cursor row's menu +/// (`panels::PanelKey::ContextMenu`). Empty off the PANELS, where no key +/// does. +pub(super) fn menu_hint(app: &App) -> &'static str { + if app.panels_active() { + "m: menu " + } else { + "" + } +} + +/// The `?` overlay's two columns while the PANELS are up: the sections +/// and the wording they always had there, a row per key the columns +/// answer to. Every chord the keymap carries is the live keymap's, as the +/// grid's help is; the PANELS' own keys (`panels::PANEL_KEYS`, +/// `panels::FOLD_KEYS`) are fixed, and spelled as such. pub(super) fn help_sections() -> (&'static [HelpSection], &'static [HelpSection]) { - use super::HelpKeys::{Act, Lit}; + use super::HelpKeys::{Act, Also, Lit}; use Action::*; const LEFT: &[HelpSection] = &[ ( - "THE COLUMNS", + "NAVIGATE & SEARCH", &[ - (Act(&[FocusLeft, FocusRight]), "walk the three columns"), - (Act(&[FocusNext]), "next column, then the pane"), - (Act(&[MoveDown, MoveUp]), "walk the column's rows"), - (Act(&[HalfPageDown, HalfPageUp]), "half a page of rows"), - (Act(&[Activate]), "drill in; session: attach"), - (Act(&[FollowUp]), "session: follow-up modal"), - (Act(&[ToggleFullScreen]), "session full-screen / back"), - (Act(&[ToggleArchived]), "fold the ARCHIVED group"), + (Also(&[FocusNext], "^⇧L"), "walk panels (fwd locks input)"), + (Lit("⇧Tab ^⇧H"), "walk panels back"), + ( + Act(&[FocusLeft, FocusRight]), + "focus left / right (2×: jump)", + ), + (Act(&[MoveDown, MoveUp]), "move selection"), + (Act(&[Activate]), "drill in / attach session"), (Act(&[Palette]), "fuzzy jump to anything"), + (Lit("^o / ^f"), "jump pick: open / focus row"), ( - Act(&[NextAttention, PrevAttention, NextProjectTab, PrevProjectTab]), + Act(&[NextProjectTab, PrevProjectTab]), "next/prev session needing you", ), + (Act(&[FindFile]), "find file (^y copies path)"), + (Act(&[Grep]), "find in files (git grep)"), + (Act(&[TreeBrowser]), "file tree browser"), + ], + ), + ( + "PROJECTS", + &[ + (Act(&[New, AddProject]), "add project (2nd: from anywhere)"), + (Act(&[Rename]), "rename row (folder keeps its name)"), + (Act(&[Delete]), "remove from list"), + ], + ), + ( + "WORKTREES", + &[ + (Act(&[New]), "new worktree (PR row: Claude)"), + (Act(&[Rename]), "run / stop the project's run command"), + (Act(&[OpenWorktree]), "fire its open command"), + (Act(&[HalfPageDown, HalfPageUp]), "half a panel down / up"), + (Act(&[GitDiff]), "git diff (^r: mark reviewed ✓, ^t: tree)"), + ( + Also(&[OpenRepo, OpenGhosttyTab], "⇧C"), + "repo on GitHub / Ghostty tab", + ), + (Act(&[RefreshPullRequests]), "refresh pull requests now"), + ( + Act(&[CommentPullRequest]), + "comment on the pull request row", + ), + (Act(&[Issues]), "github issues: prompt / preset / edit one"), ( - Act(&[CloseProjectTab, ProjectDropdown]), - "project tabs: none here", + Act(&[PullRequests]), + "pull requests: read one, launch a PR session on it", ), - (Lit("click"), "select; again: Enter"), - (Lit("wheel"), "scroll the column under it"), + (Act(&[SwitchBranch]), "switch the ⌂ root checkout's branch"), + (Act(&[Delete, DeleteAll]), "delete one / delete all"), ], ), ( - "CHECKOUTS & GITHUB", + // Every typed field — names, filters, queries — is the same + // line editor (text_input.rs). + "TYPING IN A FIELD", &[ - (Act(&[OpenWorktree]), "open in editor (open command)"), - (Act(&[GitDiff]), "diff (^r reviewed, ^t tree)"), - (Act(&[OpenRepo]), "the repo on GitHub"), - (Act(&[OpenPullRequest, OpenIssue]), "PR / issue on GitHub"), - (Act(&[RefreshPullRequests]), "reload PRs + issues (GitHub)"), - (Act(&[Issues]), "issues: prompt, preset, edit"), - (Act(&[PullRequests]), "pull requests: read / launch"), - (Act(&[SwitchBranch]), "switch the ⌂ root's branch"), + (Lit("←→ / ⌥←→"), "move by character / by word"), + (Lit("^a^e ⌥⌫ ^u^k"), "ends · del word · kill line"), ], ), ]; @@ -237,39 +773,49 @@ pub(super) fn help_sections() -> (&'static [HelpSection], &'static [HelpSection] ( "SESSIONS", &[ - (Act(&[QuickPrompt]), "quick prompt: Enter launches"), - (Act(&[New]), "new, per column"), - (Act(&[DuplicateSession]), "quick prompt as this session"), + (Act(&[New]), "new agent (pick CLI kind)"), (Act(&[AgentPresets]), "agent presets: saved launches"), + (Act(&[NewTerminal]), "new shell terminal"), + (Act(&[Activate]), "attach session / open link"), + (Act(&[HalfPageDown, HalfPageUp]), "half a panel down / up"), + (Act(&[Rename]), "rename agent / edit link URL"), + ( + Act(&[Archive, Unarchive, ToggleArchived]), + "archive / unarchive / show", + ), + (Lit("m"), "context menu (right-click)"), ( - Act(&[NewTerminal, OpenGhosttyTab]), - "terminal: here / in Ghostty", + Act(&[OpenPullRequest, OpenIssue]), + "its PR / issue on GitHub", ), - (Act(&[Rename]), "rename the session"), - (Act(&[Archive, Unarchive]), "archive / unarchive"), (Act(&[Delete, DeleteAll]), "delete one / delete all"), ], ), ( "TERMINAL & MOUSE", &[ - (Act(&[Activate]), "lock input"), - (Act(&[UnlockTerminal]), "unlock, back to the column"), + (Also(&[Activate], "/ z"), "lock input (2nd: full-screen)"), + (Act(&[UnlockTerminal]), "unlock, back to panels"), + (Act(&[ToggleFullScreen]), "full-screen / back to the pane"), (Lit("drag"), "select + copy (2×click: word)"), + (Lit("click / drag"), "an app that took the mouse gets it"), (Lit("⌥click"), "open URL / file under cursor"), (Lit("⇧drag"), "select via your terminal"), - (Lit("right-click"), "row menu: run, restart"), - (Lit("drag a border"), "resize the column"), + (Lit("drag border"), "resize panels"), (Lit("click outside"), "dismiss any modal (= Esc)"), ], ), ( "GENERAL", &[ - (Lit("⇧ + letter"), "bigger, or outside nebula"), - (Act(&[Hosts]), "ssh hosts (a: new, d: del)"), - (Act(&[Settings]), "settings; Hotkeys tab rebinds"), - (Act(&[Metrics]), "memory: nebula + agents"), + ( + Lit("⇧P / ⇧B / ⇧S"), + "collapse / expand Projects / Worktrees / Sessions", + ), + (Lit("^b ⇧Z"), "collapse every panel / bring them back"), + (Act(&[Hosts]), "ssh hosts: connect (a: new, d: del)"), + (Act(&[Settings]), "settings (Hotkeys tab rebinds these)"), + (Act(&[Metrics]), "memory usage (nebula + agents)"), (Act(&[Quit, Help]), "quit / toggle this help"), ], ), @@ -278,12 +824,22 @@ pub(super) fn help_sections() -> (&'static [HelpSection], &'static [HelpSection] } /// PROJECTS: every project on the machine, the one last worked in first — -/// what the PROJECT TABS are in the GRID, with the cursor's row the lit +/// what the PROJECT TABS are in the GRID, with the cursor's button the lit /// tab. fn draw_projects(f: &mut Frame, app: &mut App, area: Rect) { let th = app.theme; let count = app.project_rows().len(); - let list = column(f, area, "PROJECTS", count, app.focus == Focus::Projects, th); + let focused = app.focus == Focus::Projects; + let list = column( + f, + area, + "PROJECTS", + count, + focused, + th, + Focus::Projects, + &mut app.hits, + ); let lines = crate::panels::project_lines(app); let cursor = Some(Row::Project(app.sel_project)); let rows = app.project_rows(); @@ -295,52 +851,115 @@ fn draw_projects(f: &mut Frame, app: &mut App, area: Rect) { list, &lines, cursor, + cursor, Focus::Projects, + Shape::Button, |app, at, width| { let Row::Project(i) = at else { - return (Vec::new(), th.accent); + return (Vec::new(), th.accent, false); }; let Some(p) = rows.get(i).and_then(|i| app.tree.projects.get(*i)) else { - return (Vec::new(), th.accent); + return (Vec::new(), th.accent, false); }; - let roll = crate::app::project_rollup(&app.tree, &p.id); - let unseen = crate::app::project_unseen(&app.tree, &p.id); - let fresh = crate::panels::project_fresh_done(app, &p.id); - let stamped = crate::app::project_recency(&app.tree, &p.id, now).stamped; - let badge = unseen_badge(unseen); - let free = width.saturating_sub(3 + badge.chars().count()); - let (ago, name_max) = fit_ago(ago_badge(stamped), free); - let mut spans = vec![status_dot(roll, unseen > 0, th)]; - spans.extend(status_name_spans( - truncate(&p.name, name_max), - Style::default().add_modifier(Modifier::BOLD), - sweep_ramp(roll, fresh, th, app.animations), - app.sweep_phase(), - )); - push_dim(&mut spans, ago, th); - spans.push(Span::styled(badge, Style::default().fg(th.done))); - (spans, status_color(roll, unseen > 0, th)) + let (text, mark) = project_row(app, p, width, now); + (text, mark, false) }, ); } +/// One project's button: its rolled-up STATUS DOT, its name in bold — the +/// top of the tree reads biggest — how long since anything under it +/// moved, its PR & ISSUE COUNTS and the finishes under it nobody has +/// read; then, for a renamed project, the folder it still lives in on +/// the row under its name. +fn project_row( + app: &App, + p: &nebula_core::Project, + width: usize, + now: i64, +) -> (Vec>>, Color) { + let th = app.theme; + let roll = crate::app::project_rollup(&app.tree, &p.id); + let unseen = crate::app::project_unseen(&app.tree, &p.id); + let fresh = crate::panels::project_fresh_done(app, &p.id); + let stamped = crate::app::project_recency(&app.tree, &p.id, now).stamped; + let badge = unseen_badge(unseen); + let mut free = width.saturating_sub(3 + badge.chars().count()); + // The counts take their columns ahead of the ago label, and drop out + // whole, as the label does, before the name would be squeezed under + // `MIN_NAME_W`. + let counts = open_counts_badge(app.project_open_counts(&p.id), th).filter(|(_, len)| { + free.checked_sub(*len) + .is_some_and(|rest| rest >= MIN_NAME_W) + }); + if let Some((_, len)) = &counts { + free -= len; + } + // With counts after it the label ends in ` -`, so `3m ago - 4 prs` + // reads as two facts rather than one run of words; the dash is the + // label's and goes when the label goes. + let mut ago = ago_badge(stamped); + if counts.is_some() && !ago.is_empty() { + ago.push_str(" -"); + } + let (ago, name_max) = fit_ago(ago, free); + let mut spans = vec![status_dot(roll, unseen > 0, th)]; + spans.extend(status_name_spans( + truncate(&p.name, name_max), + Style::default().add_modifier(Modifier::BOLD), + sweep_ramp(roll, fresh, th, app.animations), + app.sweep_phase(), + )); + push_dim(&mut spans, ago, th); + if let Some((counts, _)) = counts { + spans.extend( + counts + .into_iter() + .map(|(text, style, _)| Span::styled(text, style)), + ); + } + spans.push(Span::styled(badge, Style::default().fg(th.done))); + let mut text = vec![spans]; + // Renaming a project is a label change, never a move on disk, so the + // folder keeps its name on the row under the label — as its child, + // not a second label: the dimmest color plus faint, hung off the name + // by a `└`. + if let Some(folder) = p.folder_subtitle() { + text.push(vec![ + Span::raw(" "), + Span::styled( + format!("└ {}", truncate(&folder, width.saturating_sub(5))), + Style::default().fg(th.dim).add_modifier(Modifier::DIM), + ), + ]); + } + (text, status_color(roll, unseen > 0, th)) +} + /// WORKTREES: the selected project's checkouts, then its open pull /// requests and issues under the headers that fold them -/// (`panels::worktree_lines`). +/// (`panels::worktree_lines`). A project with none of them says which key +/// starts one. fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { let th = app.theme; let count = app.visible_worktrees().len(); + let focused = app.focus == Focus::Worktrees; let list = column( f, area, "WORKTREES", count, - app.focus == Focus::Worktrees, + focused, th, + Focus::Worktrees, + &mut app.hits, ); - // The page Ctrl+d / Ctrl+u halve. - app.worktrees_view_rows = usize::from(list.height); + // The page Ctrl+d / Ctrl+u halve: the pills the column has room for. + app.worktrees_view_rows = usize::from(list.height) / PILL_H; let lines = crate::panels::worktree_lines(app); + if lines.is_empty() && app.selected_project().is_some() { + hint(f, app, list, &[(Action::New, " starts a worktree")]); + } let cursor = Some(Row::Worktree(app.sel_worktree)); let now = crate::app::now_ms(); draw_list( @@ -350,12 +969,14 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { list, &lines, cursor, + cursor, Focus::Worktrees, + Shape::Pill, |app, at, width| { let Row::Worktree(i) = at else { - return (Vec::new(), th.accent); + return (Vec::new(), th.accent, false); }; - match app.worktree_rows().get(i).copied() { + let (spans, mark) = match app.worktree_rows().get(i).copied() { Some(WorktreeRow::Checkout(w)) => checkout_row(app, w, false, width, now), Some(WorktreeRow::PrCheckout(w)) => checkout_row(app, w, true, width, now), Some(WorktreeRow::Pr(pr)) => { @@ -384,7 +1005,8 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { ) } None => (Vec::new(), th.accent), - } + }; + (vec![spans], mark, false) }, ); } @@ -393,6 +1015,11 @@ fn draw_worktrees(f: &mut Frame, app: &mut App, area: Rect) { /// branch — under a `└` when it is a pull request's — the RUNNING badge /// while its RUN COMMAND is up, `⌂ root` on the ROOT WORKTREE, how long /// since anything in it moved, and the finishes in it nobody has read. +/// +/// A checkout whose pull request has merged wears the merge instead +/// (`App::worktree_wears_merge`, where a live session still wins): purple +/// dot, purple rail, purple branch — swept on the merged ramp for the +/// few seconds after the merge is seen to land, solid from then on. fn checkout_row( app: &App, w: &nebula_core::Worktree, @@ -403,25 +1030,50 @@ fn checkout_row( let th = app.theme; let dim = Style::default().fg(th.dim); let pending = app.is_placeholder_worktree(&w.id); + let merged = !pending && app.worktree_wears_merge(&w.id); let roll = if pending { None } else { crate::app::worktree_rollup(&app.tree, &w.id) }; let unseen = crate::app::worktree_unseen(&app.tree, &w.id); - let fresh = crate::panels::worktree_fresh_done(app, &w.id); + let (dot, mark, ramp, name_style) = if merged { + let fresh = app.animations && app.merge_is_fresh(&w.id); + ( + Span::styled("● ", Style::default().fg(th.merged)), + th.merged, + fresh.then_some(th.merged_sweep), + Style::default().fg(th.merged), + ) + } else { + let fresh = crate::panels::worktree_fresh_done(app, &w.id); + ( + status_dot(roll, unseen > 0, th), + status_color(roll, unseen > 0, th), + sweep_ramp(roll, fresh, th, app.animations), + Style::default(), + ) + }; let badge = unseen_badge(unseen); let indent = if nested { NESTED_INDENT } else { "" }; - let run = app.worktree_running(&w.id).then_some(RUN_BADGE); - let free = width.saturating_sub( - 3 + badge.chars().count() + indent.chars().count() + run.map_or(0, |r| r.chars().count()), - ); + let free = width.saturating_sub(3 + badge.chars().count() + indent.chars().count()); + let run = app.worktree_running(&w.id).then(|| { + if w.branch.chars().count() + RUN_BADGE.chars().count() <= free { + RUN_BADGE + } else { + RUN_GLYPH + } + }); + let free = free.saturating_sub(run.map_or(0, |r| r.chars().count())); let ago = if pending { PENDING_WORKTREE_BADGE.to_string() } else { ago_badge(crate::app::worktree_recency(&app.tree, &w.id, now).stamped) }; let (ago, free) = fit_ago(ago, free); + // The root badge yields to a branch it would push into an ellipsis — + // the branch is the row's identity, the ⌂ the least of it — and + // shrinks to the bare glyph before it goes. let fits = |b: &str| w.branch.chars().count() + b.chars().count() <= free; let root = if !w.is_main { None @@ -437,11 +1089,11 @@ fn checkout_row( if nested { spans.push(Span::styled(indent, dim)); } - spans.push(status_dot(roll, unseen > 0, th)); + spans.push(dot); spans.extend(status_name_spans( truncate(&w.branch, max), - Style::default().fg(th.text), - sweep_ramp(roll, fresh, th, app.animations), + name_style, + ramp, app.sweep_phase(), )); if let Some(run) = run { @@ -455,34 +1107,49 @@ fn checkout_row( } push_dim(&mut spans, ago, th); spans.push(Span::styled(badge, Style::default().fg(th.done))); - (spans, status_color(roll, unseen > 0, th)) + (spans, mark) } -/// SESSIONS: the selected checkout's sessions, terminals, pull request and -/// archived sessions under their headers (`panels::session_lines`). A -/// checkout with nothing in it says which keys start something; a pull -/// request or an issue under the WORKTREES cursor has no sessions, and the -/// column is left empty while the pane reads it. +/// SESSIONS: the selected checkout's live sessions, then its terminals, +/// pull request and archived sessions under their headers +/// (`panels::session_lines`). A checkout with nothing in it says which +/// keys start something. A pull request or an issue under the WORKTREES +/// cursor has no sessions, and the column is never drawn then: it folds +/// to its RULE and the pane reading the row takes its width +/// (`panels::sessions_fold`). fn draw_sessions(f: &mut Frame, app: &mut App, area: Rect) { let th = app.theme; let rows = app.visible_session_rows(); let count = rows.iter().filter(|r| r.as_link().is_none()).count(); - let list = column(f, area, "SESSIONS", count, app.focus == Focus::Sessions, th); - app.sessions_view_rows = usize::from(list.height); - let lines = crate::panels::session_lines(app); + let focused = app.focus == Focus::Sessions; + let list = column( + f, + area, + "SESSIONS", + count, + focused, + th, + Focus::Sessions, + &mut app.hits, + ); + app.sessions_view_rows = usize::from(list.height) / PILL_H; + let lines = crate::panels::session_lines(app, list.width); if lines.is_empty() && app.selected_worktree().is_some() { - let key = Style::default().fg(th.accent); - let dim = Style::default().fg(th.dim); - let hint = Line::from(vec![ - Span::raw(ROW_GUTTER), - Span::styled(key_hint(app, Action::New), key), - Span::styled(" agent · ", dim), - Span::styled(key_hint(app, Action::NewTerminal), key), - Span::styled(" terminal", dim), - ]); - f.render_widget(Paragraph::new(hint), list); + hint( + f, + app, + list, + &[ + (Action::New, " agent · "), + (Action::NewTerminal, " terminal"), + ], + ); } let cursor = Some(Row::Session(app.sel_session)); + // The expanded pill pulls the window ahead of the cursor's own row: + // the box is where the keyboard is pointed, and a cursor parked + // elsewhere must not scroll the box being typed into off the screen. + let reveal = app.follow_up_row().map(Row::Session).or(cursor); let mut cfg: Option = None; draw_list( f, @@ -491,52 +1158,52 @@ fn draw_sessions(f: &mut Frame, app: &mut App, area: Rect) { list, &lines, cursor, + reveal, Focus::Sessions, + Shape::Pill, |app, at, width| { let Row::Session(i) = at else { - return (Vec::new(), th.accent); + return (Vec::new(), th.accent, false); }; - match rows.get(i) { - Some(SessionRow::Agent(a)) => agent_row(app, a, width, &mut cfg), + let row = rows.get(i); + let chevron = row.is_some_and(|row| app.takes_follow_up(row)); + let (spans, mark) = match row { + Some(SessionRow::Agent(a)) => agent_row(app, a, width, chevron, &mut cfg), Some(SessionRow::Terminal(t)) => terminal_row(t, width, th), - Some(SessionRow::Link(l)) => { - let pr = l.pull_request(); - let look = match pr { - Some(pr) => crate::pr_row::look(pr.standing(), pr.trouble(), th), - None => crate::pr_row::Look { - glyph: th.muted, - label: th.muted, - rail: th.accent, - badge: th.dim, - }, - }; - let badge = pr.map(|pr| match pr.trouble() { - Some(trouble) => (format!(" {}", trouble.badge()), look.badge), - None => (format!(" {}", pr.standing().badge()), look.badge), - }); - ( - crate::pr_row::spans(look, &l.label(), width, badge), - look.rail, - ) - } + Some(SessionRow::Link(l)) => link_row(app, l, width), None => (Vec::new(), th.accent), - } + }; + (vec![spans], mark, chevron) }, ); } /// A session's row: its STATUS DOT — gray while no PTY is behind it (the /// IDLE REAPER took it), hollow while it is a stand-in still being -/// created, `⊘` once archived — its name, how long since it moved, and the -/// harness it runs on while the name has room beside it; an unread finish -/// takes the harness's slot as ` done`, a Claude Cloud row says ` cloud`. +/// created, `⊘` once archived — its name, muted, so the bottom of the +/// tree reads smallest beside the bold projects, how long since it moved, +/// and the harness it runs on. The name is what gives way on a narrow +/// column; an unread finish takes the harness's slot as ` done`, a Claude +/// Cloud row says ` cloud`. A row that can grow a FOLLOW-UP COMPOSER +/// (`chevron`, `App::takes_follow_up`) ends in its FOLLOW-UP CHEVRON — +/// `▸` folded, `▾` in the accent expanded — taken out of the name's +/// budget before anything else is measured. fn agent_row( app: &App, a: &nebula_core::Agent, width: usize, + chevron: bool, cfg: &mut Option, ) -> (Vec>, Color) { let th = app.theme; + let chevron = chevron.then(|| { + if app.follow_up.as_ref().is_some_and(|f| f.agent == a.id) { + (FOLLOW_UP_OPEN, th.accent) + } else { + (FOLLOW_UP_FOLDED, th.dim) + } + }); + let chevron_w = chevron.map_or(0, |(glyph, _)| glyph.chars().count()); let pending = app.is_placeholder_agent(&a.id); let cold = !a.alive && a.cloud_session_id.is_none(); let dot = if a.archived { @@ -563,23 +1230,15 @@ fn agent_row( } else if a.cloud_session_id.is_some() { (" cloud".to_string(), th.dim) } else { - // The harness is the one thing on the row it can do without: on - // a column too narrow for the whole name and its age beside it, - // it goes first. let harness = if a.kind == nebula_core::AgentKind::Custom { let cfg = cfg.get_or_insert_with(crate::config::Config::load); crate::agent_picker::session_harness_badge_in(a, cfg) } else { a.kind.as_str().to_string() }; - let room = width.saturating_sub(3 + 1 + harness.chars().count() + ago.chars().count()); - if a.name.chars().count() <= room { - (format!(" {harness}"), th.dim) - } else { - (String::new(), th.dim) - } + (format!(" {harness}"), th.dim) }; - let free = width.saturating_sub(3 + badge.chars().count()); + let free = width.saturating_sub(3 + badge.chars().count() + chevron_w); let (ago, name_max) = fit_ago(ago, free); let quiet = a.archived || pending || cold; let ramp = if quiet { @@ -587,7 +1246,7 @@ fn agent_row( } else { sweep_ramp(Some(a.status), app.agent_fresh_done(a), th, app.animations) }; - let name_style = Style::default().fg(if a.archived { th.dim } else { th.text }); + let name_style = Style::default().fg(if a.archived { th.dim } else { th.muted }); let mut spans = vec![dot]; spans.extend(status_name_spans( truncate(&a.name, name_max), @@ -597,6 +1256,9 @@ fn agent_row( )); push_dim(&mut spans, ago, th); spans.push(Span::styled(badge, Style::default().fg(badge_color))); + if let Some((glyph, color)) = chevron { + spans.push(Span::styled(glyph, Style::default().fg(color))); + } let mark = if quiet { th.dim } else { @@ -623,7 +1285,7 @@ fn terminal_row( let room = width.saturating_sub(4 + name.chars().count()); let mut spans = vec![ Span::styled(glyph, Style::default().fg(color)), - Span::styled(name, Style::default().fg(th.text)), + Span::styled(name, Style::default().fg(th.muted)), ]; if let Some(command) = t.run_command.as_deref().filter(|_| room > 1) { spans.push(Span::styled( @@ -634,6 +1296,48 @@ fn terminal_row( (spans, th.accent) } +/// The checkout's pull request — or a saved link — as a row: the arrow +/// that says it leaves nebula, its `#42 title`, and its state word, or the +/// trouble GitHub says it cannot merge for. Comments other people left +/// since it was last opened from nebula take the badge over, loud — ` 2 +/// new` — as the one thing on the row worth walking over for. +fn link_row(app: &App, l: &crate::app::LinkRow, width: usize) -> (Vec>, Color) { + let th = app.theme; + let pr = l.pull_request(); + let look = match pr { + Some(pr) => crate::pr_row::look(pr.standing(), pr.trouble(), th), + None => crate::pr_row::Look { + glyph: th.muted, + label: th.muted, + rail: th.accent, + badge: th.dim, + }, + }; + let badge = pr.map(|pr| match (unseen_comments(app, pr), pr.trouble()) { + (n, _) if n > 0 => (format!(" {n} new"), th.warn), + (_, Some(trouble)) => (format!(" {}", trouble.badge()), look.badge), + (_, None) => (format!(" {}", pr.standing().badge()), look.badge), + }); + ( + crate::pr_row::spans(look, &l.label(), width, badge), + look.rail, + ) +} + +/// Comments and reviews on `pr` newer than the last one seen when it was +/// opened from nebula (`App::pr_seen`); a pull request never opened has +/// the whole conversation unread. +fn unseen_comments(app: &App, pr: &crate::pull_request::PullRequest) -> usize { + match app.pr_seen.get(&pr.url) { + Some(mark) => pr + .activity + .iter() + .filter(|at| at.as_str() > mark.as_str()) + .count(), + None => pr.activity.len(), + } +} + /// ` 2 done`: the finishes under a project or checkout nobody has read, /// in the color the session rows' own ` done` wears. Empty with none. fn unseen_badge(unseen: usize) -> String { diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index f8145065..97b7464d 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -1116,18 +1116,18 @@ fn tui_layout_setting_switches_grid_and_panels_live() { } tui.send(TAB); } + // Each `j` waits for the repaint it causes (the highlight moves, so + // the styled screen changes) before the next look: no timing guess. for _ in 0..12 { - let deadline = Instant::now() + Duration::from_millis(400); - while Instant::now() < deadline { - if row_is_selected(tui.parser.lock().unwrap().screen(), "Layout") { - tui.send(ENTER); - tui.send(ESC); - tui.wait_for_gone("Color theme"); - return; - } - std::thread::sleep(Duration::from_millis(20)); + if row_is_selected(tui.parser.lock().unwrap().screen(), "Layout") { + tui.send(ENTER); + tui.send(ESC); + tui.wait_for_gone("Color theme"); + return; } + let before = tui.parser.lock().unwrap().screen().contents_formatted(); tui.send(b"j"); + tui.wait_for("the cursor to move", |s| s.contents_formatted() != before); } panic!( "the Layout row never came under the cursor:\n{}", diff --git a/docs/configuration.md b/docs/configuration.md index 276094c5..646abd07 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,7 +70,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `preset_text` | string | `"prefix"` | Sessions | PRESET TEXT: which side of the task a new AGENT PRESET's text goes — `prefix` (one box, sent before the task), `postfix` (one box, sent after it) or `prefix & postfix` (both, the form every preset had through 0.32). The PRESET EDITOR opens a new preset with the box(es) named here, and its **Text** row changes one preset — a side the row leaves out saves blank; editing a stored preset also shows any side that already holds text, so nothing saved is ever hidden. Prefix alone by default: the framing most people reach for, and one box to fill. A hand-edited `both` reads as `prefix & postfix`; anything else as the default. See [Sessions](sessions.md#agent-presets). | | `delete_empty_worktree` | bool | `false` | Sessions | DELETE EMPTIED WORKTREE: what the delete of a linked WORKTREE's last live card asks — the last session's `d`, the last terminal's close, or a `D` that takes them all. Off, the default, that card's own CONFIRM DIALOG carries the question too, before anything is deleted (`Delete agent 'x'? Its session and history go away.` then `Nothing else is left in worktree 'feature': delete it from disk too?`), with three answers: `Enter` or `y` deletes the card and then the worktree, the same forced delete the band's own `d` runs; `n` deletes the card and keeps the empty checkout; `Esc` cancels and keeps the card alive. On, the question is not asked: the dialog is the card's ordinary two-way confirm, its message saying the worktree goes with it, and `Enter` deletes both — unless archived sessions are still filed under the checkout, whose history the delete would take: those always get the three-way question, which says how many go with it. The ROOT WORKTREE is never offered either way, and archiving a session (`a`) never counts as emptying: an archived card is still filed under its checkout. With `show_all_worktrees` on, off asks nothing — the emptied worktree keeps its band — and on still deletes the worktree with its last card. See [Sessions](sessions.md#the-grid). | | `show_all_worktrees` | bool | `true` | Sessions | SHOW ALL WORKTREES: every checkout of the project gets a BAND on the grid, one with nothing running in it too. It is on by default. Off, the grid is only what is running: a checkout with no session or terminal has no band. On, such a checkout is an EMPTY BAND — its rule over one line, `nothing running · p: new session · t: terminal · d: delete worktree` — which `j`/`k` walk onto, where `p`/`n`/`t` start work, and where `d` (or **Delete worktree** in its right-click menu) deletes the worktree behind its own confirm (`Delete worktree 'feature' from disk?`); the ROOT WORKTREE's band has no `d`. On also means deleting a worktree's last card never asks about the worktree, so the emptied band stays until its own `d` — unless `delete_empty_worktree` is on, which still deletes the worktree with its last card. The ARCHIVED VIEW never shows an empty band. See [Sessions](sessions.md#the-grid). | -| `theme` | string | `"default"` | Appearance | The THEME: `default`, `ocean`, `forest`, `rose`, `amber`, `lavender`, `coral`, `slate`, `sand`, `mono`. An unknown name falls back to `default`. | +| `theme` | string | `"default"` | Appearance | The THEME: `default`, `ocean`, `forest`, `rose`, `amber`, `lavender`, `coral`, `slate`, `sand`, `mono`. An unknown name falls back to `default`. With `layout` `panels`, the focused column or pane keeps the faintly lit gray each preset tinted it with before the grid, where the grid's tint is the accent taken down to near-black; every other colour is the preset's own. | | `animations` | bool | `true` | Appearance | Master switch for the STATUS SWEEP (running and needs-feedback rows for as long as they last; an unread finish and a just-merged checkout for about five seconds) and the SPLASH's motion. Off trades them for fewer repaints on a constrained machine. | | `focus_tint` | bool | `true` | — (retired) | Through 0.34, **Focused panel tint**: whether the FOCUSED PANEL TINT — the faint accent wash behind whatever keys land in, the card under the cursor while the grid has them and the session pane while it does — was painted at all. It always is now: the wash is the one cue that says which surface keys land in (the cursor's card itself now wears a heavy accent frame over the selection fill instead — brighter while the grid has the keys, a shade darker while the pane does — so the grid still names the session the pane reads), so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `show_workspaces` | bool | `true` | — (retired) | Through 0.33, **Workspaces bar** (Settings → Appearance): whether the bar of WORKSPACE tabs was drawn across the top. Workspaces are gone — every project is in the one list the PROJECT TABS open from — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | @@ -79,6 +79,11 @@ behaviors that change how the tree is worked; every switch there is off by defau | `highlight_current_card` | bool | `true` | Appearance | **Highlight current card** (`on` / `off`): the card under the cursor on the GRID, the one the pane reads, trades its gray fill for a very faint wash of the colour its frame would have unselected (red asking, yellow running, blue finished and unread). The wash breathes slowly while the card has something going on; a quiet card or a terminal gets a still, faint accent wash, as every card does with the animations off. It stays lit while you type in the pane and fades further when the PROJECT TABS have the keys. Off keeps the plain gray fill, dimmed whenever the keys leave the grid. | | `session_pane` | string | `"right"` | Appearance | Where the PANE that reads the card under the cursor sits: `right` (down the right of the cards, full height, half the width until its edge is dragged) or `bottom` (under the GRID, full width). The SIDE BUTTON just before the `×` on the pane's header flips it in one click — `⬓` down the right, `◨` along the bottom — and writes this same key. Its edge facing the cards is dragged the same way on both sides — a `┃` grip beside the cards, a `━` grip under them — and the width and the height are remembered apart, so switching sides never turns one into the other. A window too narrow for the pane and a column of cards side by side lays it out along the bottom until there is room (and draws no side button). Anything off the list — `left` from older builds included — reads as `right`. | | `layout` | string | `"grid"` | Appearance | What the body draws: `grid` (the PROJECT TABS over the lit project's GRID of session cards, with the pane beside them) or `panels` (the three-column layout from before the grid — PROJECTS, WORKTREES and SESSIONS side by side, each a list with a status dot per row, and the TERMINAL PANE beside them reading the session under the SESSIONS cursor; see [Keys](keys.md#the-panels) and [Sessions](sessions.md#the-panels)). Switching applies at once and keeps the selection and the attached session. Anything off the list reads as `grid`. | +| `hide_projects` | bool | `false` | Appearance | **Projects panel** (`shown` / `hidden`, under the tab's PANELS LAYOUT header): with `layout` `panels`, collapse the PROJECTS column to a slim clickable rail and give its width to the TERMINAL PANE (`Shift+P`, or the `◀` on the column's title). `Shift+P`, `Ctrl+B` and the chevron write the key as they toggle, so a collapsed column stays collapsed across restarts. The grid never reads it. | +| `hide_worktrees` | bool | `false` | Appearance | **Worktrees panel**: the same for the WORKTREES column (`Shift+B`), independently of `hide_projects`. | +| `hide_sessions` | bool | `false` | Appearance | **Sessions panel**: the same for the SESSIONS column (`Shift+S`), independently of the other two. Whatever this says, the column folds to a bare rule while the WORKTREES cursor rests on a pull request or an issue (neither has sessions to list) and opens again on the next checkout; that fold never writes the key. | +| `recent_prompts` | bool | `false` | Appearance | **Recent prompts** (`on` / `off`, under the tab's PANELS LAYOUT header): with `layout` `panels`, list the last few prompts typed into each session under its row in the SESSIONS column — the text the `UserPromptSubmit` hook carried, condensed to one line — oldest first so the bottom line is the latest ask, each with a dim `30m ago` pinned right; a click on any line lands on its session. Every harness reports its prompt (Claude, Codex and Cursor in the hook payload, Pi through its managed extension). Prompts nebula composes itself — a PR SESSION's scope, the note a `nebula worktree` relocation reopens on — are left out, and archived rows list none. Off, the rows are the single pills they always were. The grid's cards carry their session's newest prompt whatever this says, and never read it. See [Sessions](sessions.md#recent-prompts-under-a-session-pill). | +| `recent_prompts_count` | integer | `3` | Appearance | **Recent prompts shown**: how many of those prompts to list while `recent_prompts` is on. The overlay cycles `1` to `5`; a hand edit is clamped to the ten the DAEMON keeps per session (`0` reads as `1`, `50` as `10`). | | `worktree_layout` | string | `"cards"` | Appearance | How the GRID lays out each worktree's band: `cards` (a row of cards under the band's rule) or `list` (a compact list — every session and terminal one line under the rule, stacked: its status dot and name, what it runs on, and its last prompt or the shell's last line, with how long since it moved at the right). A band in the list starts collapsed, showing only its 3 most recent sessions — plus the one the cursor is on, wherever it sits — and a `▾ 2 more · Tab: see all 5` line under them; `Tab` (or a click on that line) opens the band to every entry, and `Tab` or `Esc` folds it back. `j`/`k` walk the lines as one column across the bands. Anything off the list reads as `cards`. | | `expand_all_worktrees` | bool | `false` | Appearance | **Expand all worktrees** (`on` / `off`): lay every band on the GRID out open at once — each worktree's sessions and terminals wrapped into rows under its rule (in the `list` layout, every entry listed) — instead of one band opened at a time with `Tab`. With it on there is no accordion: `Tab` and a second click on a band's rule open and fold nothing (the footer says so), and `j`/`k` walk down every worktree's rows as one column. The band `Tab` last opened is kept and is open again once this is off. | | `hide_card_prompt` | bool | `false` | — (retired) | Through 0.40, **Card prompt** (Settings → Appearance, `shown` / `hidden`): `hidden` left the last prompt off every session card on the GRID. Every card shows it now, so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | @@ -87,11 +92,6 @@ behaviors that change how the tree is worked; every switch there is off by defau | `card_line_changes` | bool | `false` | — (retired) | Through 0.37, **Card line counts** (Settings → Appearance): on, each GRID card followed its checkout's changed-file count with the lines behind it. Every card does now — `↳ feat +3 files +120 -45`, the added in the DIFF VIEWER's green and the removed in its red, counted as the DIFF VIEWER shows them (tracked files against HEAD, staged or not, and every line of an untracked file as added; a binary file, or an untracked one over 1 MiB, adds nothing) by one `git diff --numstat` beside each `git status` the file count already runs; on a narrow card the word `files` goes first, then the lines, before the branch gives up a letter — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `projects` | object | `{}` | Project | PROJECT SETTINGS: one entry per project set up differently from the rest, keyed by the project's repo path exactly as the DAEMON stores it, holding that project's rows from the **Project** tab — `{"projects": {"/Users/me/src/app": {"run_command": "npm run dev", "open_command": "open http://localhost:3000"}}}`. Two rows: **Run command** (`run_command`, string, default `""`) is the RUN COMMAND **Run** (a card's or the project tab's right-click menu) starts in *that project's* worktrees — the same shell line a `.nebula.json` `run` would carry, and the way to set one without committing a file; while it is set, **Run** runs it and never opens the file, and empty (shown as `.nebula.json`) hands the decision back to the checkout's PROJECT FILE, so a project that has one needs nothing here. Typed, not cycled: `Enter` opens a prompt titled with the project, an empty answer puts `.nebula.json` back. The DAEMON reads it fresh at each **Run**. **Open command** (`open_command`, string, default `""`) is its twin for the OPEN COMMAND `Shift+Enter` / `Shift+O` fires on that project's worktrees — `open http://localhost:3000`, say — with the same precedence over the file's `open` and the same prompt; the TUI reads it fresh at each press, since it runs on the machine you are sitting at. The tab edits the selected project and names it on its first line; with no project in the tree its rows read `n/a`. A project with no entry reads as the defaults (an empty command in each row), and an entry that only repeats them is dropped on save, so the map names only the projects that differ; an empty `run_command` or `open_command` is left out of an entry rather than written; a key inside an entry this build doesn't know — the retired `hide_root_worktree` an older build wrote among them — is carried through a save. To the file's rules the map is one key: a value in it this build can't read costs the whole map, not one project. | | `hide_root_worktree` | bool | `false` | — (retired) | Through 0.27 one switch for every project (**Hide root worktree**, Settings → Experimental), then through 0.35 the fallback for a project whose `projects` entry had no **Hide root worktree** row of its own: on, that project's ROOT WORKTREE was left out of everything the grid launched into. The root is always listed now — a launch that must not land in the shared checkout cuts a fresh worktree instead (`^N` in the QUICK PROMPT, or `quick_prompt_new_worktree`) — so this build never reads the key, here or inside an entry, and no tab edits it; both are still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | -| `hide_projects` | bool | `false` | — (retired) | Through 0.37, **Projects panel** (Settings → Appearance): on, the Projects panel of the old three-panel layout started collapsed to its rail. The GRID has no panels, so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | -| `hide_worktrees` | bool | `false` | — (retired) | Through 0.37, **Worktrees panel**: the same switch for the Worktrees panel. Never read, no row, loaded and written back as stored. | -| `hide_sessions` | bool | `false` | — (retired) | Through 0.37, **Sessions panel**: the same switch for the Sessions panel. Never read, no row, loaded and written back as stored. | -| `recent_prompts` | bool | `false` | — (retired) | Through 0.37, **Recent prompts** (Settings → Experimental): on, a session's last prompts were listed under its row. Every card carries its session's newest prompt now, whatever the key says, so this build never reads it and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | -| `recent_prompts_count` | integer | `3` | — (retired) | Through 0.37, **Recent prompts shown**: how many of those prompts that build listed, `1` to `5`. Never read, no row, loaded and written back as stored. | | `show_key_combos` | bool | `false` | — (retired) | Through 0.37, **Key combo display** (Settings → Experimental): on, each key pressed on the grid was spelled at the bottom left of the screen with what it did. The KEY COMBO DISPLAY is always on now, whatever the key says — every press shows, `j - Move down`, and clears itself three seconds on ([Keys](keys.md#chips-and-readouts)) — so this build never reads it and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `remember_harness` | bool | `false` | Experimental | REMEMBER HARNESS: a launch walked through the NEW SESSION PICKER (`n`), the PR SESSION picker or the QUICK PROMPT's `Tab` picker makes its harness the default the next launch starts on — the picker opens on that row and `p` launches it — and a model or effort drilled into through the submenus becomes that harness's own Model / Effort default. It writes the Agents tab's own rows (`quick_prompt_kind`, `_model`, `_effort`), so the tab always shows what the next launch will be; a pick that already is the default writes nothing, and an AGENT PRESET launch changes nothing, its harness being the preset's. Off, a pick is one session's: the NEW SESSION PICKER keeps opening on the `quick_prompt_kind` harness and the PR SESSION picker on its first row. See [Sessions](sessions.md#the-new-session-picker). | | `pr_issue_counts` | bool | `true` | — (retired) | Through 0.37, **PR & issue counts** (Settings → Experimental): off, the GRID's header dropped the `3 prs · 2 issues` beside the session count and the other projects' issues were never swept. The header always counts now — what is waiting on a repo is read off it without opening `v` or `i`, a click on either count opens that list, a count is left out until its list has landed, and a list cut off at the fetch cap counts `100+` — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | @@ -198,7 +198,7 @@ next ATTACH or prewarm, and an agent RESUMES its conversation there. - **Settings live in one JSON file** (`config.json`, beside the database, with `config.local.json` over it), read fresh on each use by both the daemon and the TUI, so hand edits apply without a restart. `s` opens the settings overlay over the same file: color theme, animations, which side of the cards the session pane sits on - (`session_pane`), the grid or the three panels (`layout`), whether each worktree is a row of cards or a compact list (`worktree_layout`), whether every worktree is open at once (`expand_all_worktrees`), + (`session_pane`), the grid or the three panels (`layout`) and which of the panels' columns start collapsed (`hide_projects`, `hide_worktrees`, `hide_sessions`) and how many recent prompts hang under their session rows (`recent_prompts`, `recent_prompts_count`), whether each worktree is a row of cards or a compact list (`worktree_layout`), whether every worktree is open at once (`expand_all_worktrees`), editor, the branch new worktrees start from (`worktree_base_branch`: `auto` for origin's default branch, or a name such as `master`, typed into a prompt that `Enter` opens on the row), which agent CLIs the new-session menu offers (at least one stays on) and their default model diff --git a/docs/keys.md b/docs/keys.md index 732a723d..b3d864a5 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -128,18 +128,27 @@ PROJECTS column lists every project and stands in for the PROJECT TABS. | Key | Action | |---|---| -| `h`/`l` or `←`/`→`, `Tab` | move FOCUS across the columns; `h` stops at PROJECTS, a double `l` at SESSIONS (or `Tab`) crosses into the pane and takes its input | +| `h`/`l` or `←`/`→`, `Tab` | move FOCUS across the columns — the panels open with it on PROJECTS; `h` stops at PROJECTS, a double `l` at SESSIONS (or `Tab`) crosses into the pane and takes its input | +| `Shift+Tab` (or `Ctrl+Shift+H`), `Ctrl+Shift+L` | walk FOCUS back a column, stopping at the first, or forward as `Tab` does. The `Ctrl+Shift` pair needs the kitty protocol; inside a LOCKED PANE `Ctrl+Shift+H` stays the hatch out | +| `Ctrl+→` | FOCUS one step right — from SESSIONS into the pane — without taking the input lock, so the pane is read but not typed into | | `j`/`k` or `↓`/`↑`, `Ctrl+d`/`Ctrl+u` | move the focused column's cursor: a project scopes the WORKTREES, a checkout the SESSIONS, and a session comes up in the pane | | `Enter` | drill in: PROJECTS → WORKTREES → SESSIONS; on a session, attach it and type into it; on a pull request or an issue, open it in the browser | | `n` | per column: add a project, cut a worktree, or pick a harness for a new session | | `p`, `t`, `r`, `a`, `u`, `d`, `/`, `.`/`,`, `g`, `f`, `?` … | as everywhere else, on the selected project, checkout or session | -| `Space` | on a session: its FOLLOW-UP MODAL | -| `^F` | the session under the cursor full-screen; `^F` or `^q` comes back down to the panels' pane | +| `Space` | **follow-up prompt, in the pill itself.** The selected session's pill expands in place into a small box — no modal over the screen — and what you type there goes to the agent already running in that session as its next turn: `Enter` sends it and folds the pill back up, `Shift+Enter` / `Option+Enter` / `Ctrl+J` break a line, `Esc` folds without sending; a paste, or a file dropped on the window, lands in the box. Every pill below it in the column moves down by what the box took, and off the bottom if the column runs out — the column scrolls to keep the box you are typing into on screen. While the box is open it owns the keyboard, so the column's own letters (`a`, `d`, `r`, `m`, `z`…) are just letters; `Tab` still walks on and leaves the pill expanded behind it, and a click on another pill folds it. Each pill carries the toggle it answers to: a `▸` at the end of its name row that a click expands, and a `▾` that a click folds — the same thing the `m` menu's **Follow-up prompt** row does. Only a live local agent has one: an archived session, a Claude Cloud row (whose `m` menu queues a message instead), a terminal and a pull request row have nothing to follow up on, and say so. A session whose CLI is not up is booted first and the box kept as it is, so nothing is typed into a process that is still starting — press `Enter` again once it is. Rebindable as `follow_up` | +| `z` | the session in the pane full-screen with its input locked — the columns give way; `^q` (or any unlock hatch) comes back to the columns. With no session in the pane the footer says to attach one first | +| `^F` | the session under the cursor full-screen; `^F` comes back down to the panels' pane with the keys still in it, `^q` to the columns | +| `m` | the CONTEXT MENU of the row under the focused column's cursor — what a right-click on it opens — near the columns' top left. A column with no row under its cursor has none; the column's own verbs (**Show/hide open PRs**, **Show/hide issues**, **Hide draft PRs**, **Show/hide archived**) are a right-click on its empty background. A session row's menu is the session's alone (**Attach**, **Follow-up prompt**, **Restart**, **Duplicate**, **Rename**, **Archive**, **Delete**; **Send to cloud session** on a cloud row): the checkout's verbs are its WORKTREES row's. The footer ends `m: menu ?: help` on every column | +| `Shift+C` | a Ghostty tab in the selected checkout, as `Shift+T` | | `Shift+A` | open or fold the SESSIONS column's ARCHIVED group | | `]` / `[` | the attention walk, as `.` / `,` | | `x`, `1`–`9`, `+`, `` ` ``, `` ^` `` | grid-only — the PROJECT TABS, the pane's terminal strip and its fold have nothing to act on here, and the footer says so | -| click | a row selects it and its column takes the keys, a second click is `Enter` on it; a click on a group header (`OPEN PRS`, `ISSUES`, `ARCHIVED`) folds it; a click on the pane steps into it | +| click | a row selects it and its column takes the keys, a second click is `Enter` on it — a checkout hands the keys to its sessions, a pull request or an issue opens in the browser, a session attaches, a link opens; an archived session says to unarchive it first. A click on a group header (`OPEN PRS`, `ISSUES`, `ARCHIVED`) folds it; a click on a session's `▸` / `▾` expands its pill into the follow-up box or folds it (`Space`), and a click inside the open box only gives SESSIONS the keys; a click on the pane steps into it | | wheel | over a column longer than the screen: scroll it under the cursor, which stays put; the next key that moves the cursor brings it back on screen. `?` lists the panels' keys while they are up | +| `Shift+P` | collapse / expand the PROJECTS column to a slim clickable rail. The TERMINAL PANE takes the released width; expanding restores its remembered width without stealing FOCUS. The `◀` at the right of the column's title does the same by mouse, and a click on the rail's `▶` brings it back. Persisted as `hide_projects` in CONFIG.JSON and also available in Settings → Appearance. Beside the columns this wins over the grid's `Shift+P` (**Duplicate session**, which stays on a session row's right-click menu) | +| `Shift+B` | collapse / expand the WORKTREES column the same way, independently of PROJECTS. Persisted as `hide_worktrees` | +| `Shift+S` | collapse / expand the SESSIONS column the same way. Persisted as `hide_sessions`. Apart from that, SESSIONS folds to a bare rule of its own while the WORKTREES cursor is on a pull request or an issue — neither has sessions — and opens again on the next checkout; that fold writes nothing | +| `Ctrl+B` (or `⌘B`, or `Shift+Z`) | collapse every column to its rail, FOCUS going into the pane, or bring them all back. The one-keystroke way to give the TERMINAL PANE the full width; `⌘` for emulators that encode it, `Shift+Z` for a tmux whose prefix is `Ctrl+B`. A collapsed column's key leads the footer (`⇧P: show projects`), and `h`/`l` and `Tab` step over it | | drag a border | a column's right border — its rule, with the grip down its middle — resizes that column, and the pane takes up the difference; a column stops at 10 columns wide and the pane at 20. The widths are remembered across restarts, and a window too narrow for them squeezes the columns without forgetting them. Grabbing a border selects nothing and moves no FOCUS | ## Chips and readouts @@ -162,7 +171,7 @@ The FOOTER carries the rest, left to right: | `⇡ v0.22.0` right after it, in the warning color | a newer nebula is published on GitHub — the nudge to run `nebula upgrade`. Checked once at start and hourly after with one `curl` to the release page's redirect (no `gh` token, no API quota); it says nothing while it can't ask, and `NEBULA_UPDATE_CHECK_SECS=0` turns the check off | | `✗ disconnected` | this client has lost the DAEMON. It is the client-to-daemon link and nothing else — a session's own health is its `●` STATUS DOT, and a hollow `○` is an agent the daemon restarted out from under. A healthy connection says nothing at all: the bar reports the link only when something is wrong | | `2 agents · 1 term · 3 warm · 412 MB` at the right edge | live counts and nebula's whole memory footprint — this TUI, the DAEMON and every session's process subtree summed — re-read every 5 seconds while no modal is up. Prewarm spares are counted apart as `warm` so the agent figure matches the cards, and the whole readout is blank until the first reading lands. It is a button: the pointer on it underlines it, and a click opens the memory modal, the same one `Shift+M` opens | -| `j - Move down` on the blank row above the bar, far left | the KEY COMBO DISPLAY, always on: the key you just pressed in a keycap and what it did, for anyone watching a screen share — vim's `showcmd`. A double tap reads as one combo (`k k - Project tabs`), an unbound key bare, and it clears itself three seconds after the press. Keys typed into a LOCKED PANE never show (only the hatch out and the pane fold do), nor does anything typed into a modal's text field — in a modal only `Esc`, `Enter`, `Tab`, the arrows and `^`/`⌥` chords show, bare | +| `j - Move down` on the blank row above the bar, far left | the KEY COMBO DISPLAY, always on beside the grid (the panels keep the row blank): the key you just pressed in a keycap and what it did, for anyone watching a screen share — vim's `showcmd`. A double tap reads as one combo (`k k - Project tabs`), an unbound key bare, and it clears itself three seconds after the press. Keys typed into a LOCKED PANE never show (only the hatch out and the pane fold do), nor does anything typed into a modal's text field — in a modal only `Esc`, `Enter`, `Tab`, the arrows and `^`/`⌥` chords show, bare | ## Mouse diff --git a/docs/sessions.md b/docs/sessions.md index fc0cb554..b902ea19 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -495,23 +495,92 @@ Settings → Appearance → **Layout** `panels` puts the three-column layout bac PROJECTS, WORKTREES and SESSIONS side by side, and the TERMINAL PANE beside them. Every launch path above works from it, on the selection the columns show. -- **PROJECTS** lists every project on the machine, the one last worked in first, each with the - rolled-up status dot of its sessions, how long since anything in it moved and its unread finishes. +- **PROJECTS** lists every project on the machine, the one last worked in first, each a 3-row button + with the rolled-up status dot of its sessions, its name in bold, how long since anything in it + moved, its PR and issue counts (`3m ago - 3 prs · 2 issues`, dropped whole on a column too narrow + for them beside the name) and its unread finishes. A renamed project names the folder it lives in + on the row under its name (`└ api-server`). - **WORKTREES** lists the selected project's checkouts — the root first, `⌂ root` — then the project's open pull requests under `OPEN PRS` (a checkout on a pull request's branch nested under - it with a `└`) and its open issues under `ISSUES`. A click on either header folds the group. Resting - on a pull request or an issue reads it in the pane. -- **SESSIONS** lists the selected checkout's sessions under `RECENT`, most recently touched first, - then its `TERMINALS`, its `PULL REQUESTS` and the `ARCHIVED` group, folded to its count until - `Shift+A` or a click opens it. Walking the list shows each session in the pane; `Enter` steps - into it. -- `n` in SESSIONS is the NEW SESSION PICKER for the selected checkout, `p` the QUICK PROMPT, `Space` - the FOLLOW-UP MODAL. The breadcrumb in the footer reads `project ▸ worktree ▸ session`. + it with a `└`) and its open issues under `ISSUES`. A click on either header folds the group. The + `OPEN PRS` count reads `9/12` while drafts are hidden — and the header stays when every pull + request is a hidden draft — and either count ends in `+` when the list hit GitHub's fetch cap. + A checkout whose pull request has merged goes purple, its branch sweeping once as the merge + lands; one whose run command is up says `▶ running` (`▶` alone when the word would cut the + branch). Resting on a pull request or an issue reads it in the pane, and the SESSIONS column + folds to its bare rule — a pull request or an issue has no checkout, so no sessions to list — so + the pane takes the width until the cursor steps back onto a checkout. That fold is the row's, not + yours: `hide_sessions` is neither read nor written by it. A project with nothing to list says + `n starts a worktree`. +- **SESSIONS** lists the selected checkout's live sessions, most recently touched first, as one list + with no header, then its `TERMINALS`, its `PULL REQUESTS` and the archived ones — folded to + `… 3 archived` until `Shift+A` or a click opens it as `ARCHIVED · 3`. The checkout's pull request + says how many comments landed since it was last opened from nebula (`2 new`) in place of its state. + Walking the list shows each session in the pane; `Enter` steps into it. With **Recent prompts** on + (Settings → Appearance, under PANELS LAYOUT) each live session's pill lists its last prompts under + its name ([RECENT PROMPTS](#recent-prompts-under-a-session-pill)). +- Checkouts and sessions are 2-row pills: the cursor's row is a raised slab with a rail down its + left edge in the row's status color, dim while its column hasn't the keys. +- The panels open with the keys on PROJECTS. `n` in SESSIONS is the NEW SESSION PICKER for the + selected checkout, `p` the QUICK PROMPT, `Space` the session's + [FOLLOW-UP COMPOSER](#the-follow-up-composer-in-a-session-pill). The breadcrumb in the + footer reads `project ▸ worktree ▸ session`, followed by the selected checkout's changed files + (`+3 files`), and its hints end `m: menu ?: help`; the key combo echo stays off. +- The keys the columns always had are theirs again: `Shift+Tab` walks FOCUS back a column, `Ctrl+→` + steps into the pane without typing into it, `z` full-screens the pane with its input locked + (`^q` comes back to the columns), `m` opens the cursor row's right-click menu near the columns' top + left, and `Shift+C` opens a Ghostty tab. `?` lists them in the help's old + sections ([Keys](keys.md#the-panels)). +- The focused column, or the pane, wears the faintly lit gray tint the panels always had, not the + grid's near-black accent. - Dragging a column's right border resizes it, the pane taking up the difference; the widths are remembered across restarts. - -The cards' own extras — the last prompt and line counts on a card, the header's PR and issue -counts, the pane's terminal strip — are the grid's and have no column here. +- Each column's title carries a `◀` that collapses the column to a slim rail, the pane taking its + width; a click on the rail's `▶` brings it back at the width it had. `Shift+P`, `Shift+B` and + `Shift+S` do the same from the keyboard, `Ctrl+B` (or `Shift+Z`) every column at once. A collapsed + column is skipped by `h`/`l` and `Tab`, its key leads the footer (`⇧P: show projects`), and it + stays collapsed across restarts (`hide_projects`, `hide_worktrees`, `hide_sessions`, also in + Settings → Appearance). + +The cards' own extras — the last prompt and line counts on a card, the pane's terminal strip — are +the grid's and have no column here. + +### The FOLLOW-UP COMPOSER in a session pill + +The next turn for a session already running, typed into its own pill. Put the cursor on an agent's row +in the SESSIONS column and press `Space` — or click the `▸` at the end of its name row, or pick +**Follow-up prompt** from its `m` menu — and the pill expands in place: a small framed box opens inside +it, under whatever RECENT PROMPTS the row carries, with `follow-up` on its top border and the keys on +its bottom one. There is no modal over the screen; the columns stay exactly where they were, and every +pill below this one moves down by what the box took, off the bottom of the column if it runs out. The +column scrolls to keep the box you are typing into on screen, and goes on doing so as the box grows — +it takes up to four lines of text before it starts scrolling under its own caret. + +`Enter` sends what you typed to the agent as its next turn and folds the pill back up, with +`sent to ` in the footer; `Shift+Enter`, `Option+Enter` and `Ctrl+J` break a line, and `Esc` +folds the pill without sending. The text goes straight down the session's PTY, and the pane swaps to +that session so you can watch the turn land; a prompt with line breaks in it crosses as one bracketed +paste. While the box is open it owns the keyboard: the column's verbs are bare letters, so `a`, `d` +and `r` are letters in your prompt and not archive, delete and rename aimed at the session you are +prompting. `Tab` still walks on and leaves the pill expanded behind it, and clicking another pill folds +the box. The toggle on each pill says which state it is in — `▸` folded, `▾` expanded — and a click +on it does either. Only a live local agent has a pill to expand; an archived session, a Claude Cloud +row, a shell terminal and a pull request row each say what they take instead. A session whose CLI is +not up is booted first and the box left as it is, so press `Enter` again once it is up. The grid's +cards open the [FOLLOW-UP MODAL](#the-follow-up-composer) instead. + +### RECENT PROMPTS under a session pill + +With **Recent prompts** on (Settings → Appearance, under PANELS LAYOUT; `recent_prompts` in +CONFIG.JSON) every live session row in the SESSIONS column grows a short list under its pill: the last +few prompts typed into it, oldest first so the bottom line is the latest ask, each condensed to one line +and clipped to the column, with a dim `30m ago` pinned to the right. **Recent prompts shown** +(`recent_prompts_count`, `3` by default, `1` to `5` in the overlay) says how many; the DAEMON keeps the +newest ten per session — the same history the grid's cards read their last prompt from — so raising +the number later has history to draw from at once. Prompts nebula composes itself are left out. The +lines belong to their row: they sit inside its pill, and on the row the cursor is on they take the +pill's fill with the rail running down beside them. A click on any of them lands on the session, +archived rows list none, and off, the rows are the single pills they always were. ## The ISSUES MODAL and ISSUE SESSIONS From e4eb07b40ab1cc4afe05d9e2e4fbde6fcb03f4b6 Mon Sep 17 00:00:00 2001 From: David Budac Date: Sun, 4 Oct 2026 15:48:12 +0200 Subject: [PATCH 4/5] The pane only takes input when it shows a live session, in both layouts, and full screen refuses cleanly Touches the grid too: - A PR, issue or cloud reader covering a retained attachment no longer lets keys, paste, mouse reports or a pending attach through to the hidden PTY (`App::pane_shows_terminal` / `App::pane_accepts_input`). Page/Home/End scroll the reader from a focused pane. - An archived agent is refused with a flash instead of attaching (`App::is_archived_session`, replacing three inlined checks). - Full screen works on any terminal surface, exited output and starting sessions included, and otherwise refuses with one message; the KEY COMBO DISPLAY only says "Full screen" when it happened. Panels only: - Activating a checkout walks past a folded SESSIONS; the quick prompt targets the visible checkout and carries a selected PR or issue. - A click elsewhere breaks a session row's double-click. Co-Authored-By: Claude Opus 5.5 --- crates/nebula-tui/src/app.rs | 44 +- crates/nebula-tui/src/event_loop.rs | 72 ++- crates/nebula-tui/src/event_loop/activate.rs | 6 +- .../nebula-tui/src/event_loop/focus_walk.rs | 7 +- crates/nebula-tui/src/event_loop/launcher.rs | 49 +- crates/nebula-tui/src/event_loop/panels.rs | 448 +++++++++++++++++- crates/nebula-tui/src/launcher.rs | 7 +- 7 files changed, 569 insertions(+), 64 deletions(-) diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 435bc4ea..8583fa2e 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -4627,6 +4627,40 @@ impl App { .is_some_and(|t| self.is_placeholder_session(&t.sref)) } + /// The attachment is the pane's visible content, rather than a PR, + /// issue or cloud reader covering it. Exited output and starting + /// sessions still belong to this surface and can be full-screened. + pub fn pane_shows_terminal(&self) -> bool { + self.term.is_some() + && self.rows_memo.hold(|| { + self.previewed_pr().is_none() + && self.previewed_issue().is_none() + && self.previewed_cloud().is_none() + }) + } + + /// Whether the visible pane has a live PTY that can receive input. + /// A reader can cover a retained attachment without detaching it. + pub fn pane_accepts_input(&self) -> bool { + self.pane_shows_terminal() + && self.term.as_ref().is_some_and(|term| { + !term.exited + && !self.is_placeholder_session(&term.sref) + && !self.is_archived_session(&term.sref) + }) + } + + /// Is this the ARCHIVED agent `id`? Its session was reaped when it was + /// archived, so nothing on it can be stepped into. + pub fn is_archived_agent_id(&self, id: &AgentId) -> bool { + self.tree.agents.iter().any(|a| &a.id == id && a.archived) + } + + /// [`App::is_archived_agent_id`] for any session: a terminal never is. + pub fn is_archived_session(&self, sref: &SessionRef) -> bool { + matches!(sref, SessionRef::Agent(id) if self.is_archived_agent_id(id)) + } + /// The mouse protocol the program in the pane has asked for, and /// whether it wants SGR coordinates. `None` when nothing there can take /// a report: no session, one whose process has exited (its last screen @@ -4638,15 +4672,7 @@ impl App { let Some(term) = &self.term else { return mouseless; }; - // Asked on every wheel notch over the pane: one reading of the - // cursor for all four (`RowsMemo`). - let reading_something_else = self.rows_memo.hold(|| { - self.pane_shows_placeholder() - || self.previewed_pr().is_some() - || self.previewed_issue().is_some() - || self.previewed_cloud().is_some() - }); - if term.exited || reading_something_else { + if !self.pane_accepts_input() { return mouseless; } let screen = term.parser.screen(); diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index b6a31bf4..f638d93f 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -2470,7 +2470,7 @@ fn dispatch_terminal_event(app: &mut App, event: Event, out: &mut Vec) { Event::Paste(text) => { // A stand-in pane (QUICK PROMPT, checkout still being cut) has // no PTY to paste into. - if app.focus == Focus::Terminal && app.term_locked && !app.pane_shows_placeholder() { + if app.focus == Focus::Terminal && app.term_locked && app.pane_accepts_input() { if let Some(term) = &app.term { let session = term.sref.clone(); let data = pasted(term.parser.screen(), &text); @@ -2575,7 +2575,7 @@ fn typing_into_pane(app: &App) -> bool { && app.flash.is_none() && app.term_selection.is_none() && app.key_combo.is_none() - && !app.pane_shows_placeholder() + && app.pane_accepts_input() && app .term .as_ref() @@ -2990,7 +2990,7 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { && (is_hatch || folds_launcher_pane(app, &chord))); if app.launcher_active() && zooms { let did = launcher::toggle_full_screen(app, out); - crate::key_combo::note(app, &[chord], Some(did)); + crate::key_combo::note(app, &[chord], did); return; } if is_hatch { @@ -3036,7 +3036,7 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { // A stand-in pane (QUICK PROMPT, checkout still being cut) has no // PTY behind it: the keystroke has nowhere to go until the real // session attaches, and must not land in the previous one. - let stand_in = app.pane_shows_placeholder(); + let unavailable = !app.pane_accepts_input(); if !exited { if let Some(term) = &mut app.term { // Typing changes the content under a persisted selection @@ -3049,7 +3049,7 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { if term.scroll_offset() > 0 { term.set_scroll(0); } - if stand_in { + if unavailable { return; } if let Some(data) = keys::encode_key(&key, term.kitty_flags) { @@ -3082,9 +3082,14 @@ fn handle_key(app: &mut App, key: KeyEvent, out: &mut Vec) { // Reading a pull request or an issue in the pane: the diff modal's // scroll keys work here too. Page/Home/End only — shift+↑/↓ already // move a project, and ↑/↓ have to keep walking the list itself. From - // either list that can rest on one; a focused pane keeps its keys for - // the PTY. - if app.reading_url().is_some() && matches!(app.focus, Focus::Worktrees | Focus::Sessions) { + // either list that can rest on one, or the pane when it shows a + // reader rather than the PTY. + if app.reading_url().is_some() + && matches!( + app.focus, + Focus::Worktrees | Focus::Sessions | Focus::Terminal + ) + { let page = app.term_area.height.max(1); let max = app.pr_preview_max_scroll(); let scrolled = match key.code { @@ -8003,6 +8008,10 @@ fn attach_selected(app: &mut App, out: &mut Vec) { let Some(row) = rows.get(app.sel_session) else { return; }; + if row.is_archived_agent() { + app.flash = Some(AGENT_ARCHIVED.into()); + return; + } let Some(sref) = row.sref() else { if let Some(link) = row.as_link() { open_link(app, link.url(), out); @@ -8035,14 +8044,28 @@ fn cloud_session_url_of(app: &App, sref: &SessionRef) -> Option { /// double-click on a card step into the pane under the cards /// (`launcher::enter_pane`); only a body too short to draw that pane /// comes here instead (`launcher::open_session`). [`leave_terminal_lock`] -/// is its undo. -pub(super) fn zoom_pane(app: &mut App, out: &mut Vec) { +/// is its undo. False, with a flash, when the pane has no terminal to take +/// the screen (nothing attached, or a reader covering it). +pub(super) fn zoom_pane(app: &mut App, out: &mut Vec) -> bool { + // The readers cover the pane by what has FOCUS, so ask as the full + // screen would have it. + let previous_focus = std::mem::replace(&mut app.focus, Focus::Terminal); + if !app.pane_shows_terminal() { + app.focus = previous_focus; + app.flash = Some(NOTHING_TO_FULL_SCREEN.into()); + return false; + } app.collapsed = true; - app.focus = Focus::Terminal; app.term_locked = true; - fire_pending_attach(app, out); + if app.pane_accepts_input() { + fire_pending_attach(app, out); + } + true } +/// What `^F` and `z` say with nothing in the pane to full-screen. +pub(super) const NOTHING_TO_FULL_SCREEN: &str = "no session in the pane — j/k onto one, then ^F"; + /// Leave a locked pane for the cards (`Focus::Sessions`). Also ends a /// full screen, so there is something on screen to land in — which is /// what takes a full-screen session back to the LAUNCHER VIEW's GRID. @@ -8630,6 +8653,9 @@ fn forward_mouse( release: bool, mouse: &MouseEvent, ) { + if !app.pane_accepts_input() { + return; + } if let Some(term) = &app.term { let (col, row) = pane_cell(app.term_area, mouse.column, mouse.row); out.push(ClientRequest::Input { @@ -9146,6 +9172,20 @@ fn update_pointer(app: &mut App, mouse: &MouseEvent) { } fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) { + // Only another row click can continue a row's double-click gesture. + if app.panels_active() && matches!(mouse.kind, MouseEventKind::Down(_)) { + let row_click = mouse.kind == MouseEventKind::Down(MouseButton::Left) + && app.overlay.is_none() + && matches!( + app.hit_at(mouse.column, mouse.row), + Some(HitTarget::PanelsRow( + crate::panels::Row::Session(_) | crate::panels::Row::Worktree(_) + )) + ); + if !row_click { + app.last_session_click = None; + } + } let mouse_pos = ratatui::layout::Position::new(mouse.column, mouse.row); update_pointer(app, &mouse); // The editor modal swallows the mouse entirely — its selection/scroll @@ -9658,6 +9698,7 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // (Cmd never reaches us — the SGR mouse protocol has no such // bit — so Option is the "open link" modifier.) if mouse.modifiers.contains(KeyModifiers::ALT) + && app.pane_shows_terminal() && matches!( app.hit_at(mouse.column, mouse.row), Some(HitTarget::TerminalPane) @@ -9831,6 +9872,11 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) // pane the daemon had not been asked for yet. if let Some(sref) = app.term.as_ref().map(|t| t.sref.clone()) { enter_terminal_pane(app, out); + if !app.pane_shows_terminal() { + app.last_term_click = None; + app.dirty = true; + return; + } let cell = pane_cell(app.term_area, mouse.column, mouse.row); let (mode, sgr) = app.child_mouse_mode(); if mode != vt100::MouseProtocolMode::None { diff --git a/crates/nebula-tui/src/event_loop/activate.rs b/crates/nebula-tui/src/event_loop/activate.rs index 47190171..d8339a6e 100644 --- a/crates/nebula-tui/src/event_loop/activate.rs +++ b/crates/nebula-tui/src/event_loop/activate.rs @@ -160,7 +160,7 @@ pub(super) fn worktrees_row(app: &mut App, out: &mut Vec) { .or_else(|| app.selected_worktree_issue().map(|i| i.url.clone())); match link { Some(url) => open_link(app, &url, out), - None => app.focus = Focus::Sessions, + None => super::walk_focus_forward(app, out), } } @@ -179,6 +179,10 @@ pub(super) fn cloud_link(app: &mut App, out: &mut Vec) -> bool { /// on one, **Attach** in its CONTEXT MENU: the pane shows the session, takes /// FOCUS and the input lock. pub(super) fn attach(app: &mut App, sref: SessionRef, out: &mut Vec) { + if app.is_archived_session(&sref) { + app.flash = Some(super::AGENT_ARCHIVED.into()); + return; + } attach_now(app, sref, out); app.focus = Focus::Terminal; app.term_locked = true; diff --git a/crates/nebula-tui/src/event_loop/focus_walk.rs b/crates/nebula-tui/src/event_loop/focus_walk.rs index 9c63c441..6f2cc967 100644 --- a/crates/nebula-tui/src/event_loop/focus_walk.rs +++ b/crates/nebula-tui/src/event_loop/focus_walk.rs @@ -19,8 +19,11 @@ use std::time::Duration; /// waiting: keystrokes are about to need it. pub(super) fn enter_terminal_pane(app: &mut App, out: &mut Vec) { app.focus = Focus::Terminal; - if app.term.as_ref().is_some_and(|t| !t.exited) { - app.term_locked = true; + // Starting sessions retain their lock and escape hatches while the + // daemon is creating the PTY; they cannot accept input yet. + app.term_locked = + app.pane_accepts_input() || (app.pane_shows_terminal() && app.pane_shows_placeholder()); + if app.pane_accepts_input() { fire_pending_attach(app, out); } } diff --git a/crates/nebula-tui/src/event_loop/launcher.rs b/crates/nebula-tui/src/event_loop/launcher.rs index 28421d6d..443b128c 100644 --- a/crates/nebula-tui/src/event_loop/launcher.rs +++ b/crates/nebula-tui/src/event_loop/launcher.rs @@ -34,12 +34,6 @@ const LIVE_VIEW: &str = "live sessions — ⇧A reads the archived ones"; const NO_SESSIONS: &str = "no sessions yet — p starts one"; const NO_ARCHIVED_SESSIONS: &str = "nothing archived here — ⇧A back to the live sessions"; -/// Is the card under this id one of the ARCHIVED VIEW's? Its session was -/// reaped when it was archived, so nothing on it can be stepped into. -fn is_archived(app: &App, id: &AgentId) -> bool { - app.tree.agents.iter().any(|a| &a.id == id && a.archived) -} - /// The one of those two this grid means. fn nothing_here(app: &App) -> &'static str { if app.show_archived { @@ -88,6 +82,12 @@ pub(super) const UNAIMED: &str = "nothing selected — j/k or a click picks a ca /// on the card) — so which of the worktree's cards the cursor last /// rested on does not matter, only which worktree. pub(super) fn open_box(app: &mut App) { + if app.panels_active() + && (app.selected_worktree_pr().is_some() || app.selected_worktree_issue().is_some()) + { + crate::quick_prompt::open_quick_prompt(app); + return; + } if let Some(launch) = box_launch(app) { crate::quick_prompt::open_box(app, launch); } @@ -1988,7 +1988,7 @@ pub(super) fn enter_pane(app: &mut App, out: &mut Vec) { // An ARCHIVED card has no session to read: the daemon reaped // it when it was archived. Say what to press rather than // handing the keys to an empty pane. - if is_archived(app, &id) { + if app.is_archived_agent_id(&id) { app.flash = Some(super::AGENT_ARCHIVED.into()); return; } @@ -2009,29 +2009,27 @@ pub(super) fn enter_pane(app: &mut App, out: &mut Vec) { /// session takes the whole screen. The jump attaches the card outright, /// so a card the pane's debounce had not reached yet is the one that /// comes up. -pub(super) fn open_session(app: &mut App, out: &mut Vec) { +pub(super) fn open_session(app: &mut App, out: &mut Vec) -> bool { let Some(sref) = cursor_or_first(app) else { app.flash = Some(nothing_here(app).into()); - return; + return false; }; match sref { SessionRef::Terminal(id) => { select_card(app, SessionRef::Terminal(id.clone()), out); super::attach_now(app, SessionRef::Terminal(id), out); - super::zoom_pane(app, out); + super::zoom_pane(app, out) } SessionRef::Agent(id) => { - if is_archived(app, &id) { + if app.is_archived_agent_id(&id) { app.flash = Some(super::AGENT_ARCHIVED.into()); - return; + return false; } take_aim(app); jump_to_target(app, PaletteTarget::Session(id), Landing::Attach, out); // A Cloud row's Enter is its browser page, not a PTY: the jump // has already opened it and there is nothing to full-screen. - if app.term.is_some() { - super::zoom_pane(app, out); - } + app.term.is_some() && super::zoom_pane(app, out) } } } @@ -2044,28 +2042,31 @@ pub(super) fn open_session(app: &mut App, out: &mut Vec) { /// A session full-screened for want of a pane (a body too short to draw /// one, the pane folded away) has nothing to come back down to, and /// lands on the grid the way the crumb always took it. Returns what it -/// did, for the KEY COMBO DISPLAY. +/// did, for the KEY COMBO DISPLAY: nothing when it refused. /// /// INPUT PARITY: the one function behind the chord (from the grid, or let /// through a LOCKED PANE), `^q` and `^`` in a full-screen session, the /// `‹ sessions` crumb and the header's button. -pub(super) fn toggle_full_screen(app: &mut App, out: &mut Vec) -> &'static str { +pub(super) fn toggle_full_screen( + app: &mut App, + out: &mut Vec, +) -> Option<&'static str> { app.dirty = true; if app.collapsed { app.collapsed = false; // The PANELS always have their pane beside the columns. if !app.panels_active() && (app.launcher_pane_hidden || !has_pane(app)) { super::leave_terminal_lock(app); - return "Back to the grid"; + return Some("Back to the grid"); } - return NORMAL_SIZE; + return Some(NORMAL_SIZE); } - if app.focus == Focus::Terminal && app.term.is_some() { - super::zoom_pane(app, out); + let zoomed = if app.focus == Focus::Terminal && app.term.is_some() { + super::zoom_pane(app, out) } else { - open_session(app, out); - } - FULL_SCREEN + open_session(app, out) + }; + zoomed.then_some(FULL_SCREEN) } /// What [`toggle_full_screen`] says it did, for the KEY COMBO DISPLAY. diff --git a/crates/nebula-tui/src/event_loop/panels.rs b/crates/nebula-tui/src/event_loop/panels.rs index 1a79ff34..38e0d50a 100644 --- a/crates/nebula-tui/src/event_loop/panels.rs +++ b/crates/nebula-tui/src/event_loop/panels.rs @@ -17,7 +17,7 @@ use super::{ activate, attach_selected, context_menu_items, is_double_click, jump_attention, open_ghostty_tab, open_menu, select_project_row, select_session_row, select_worktree_row, toggle_issues, toggle_open_prs, walk_focus_back, walk_focus_forward, zoom_pane, - KEYBOARD_MENU_ANCHOR, + KEYBOARD_MENU_ANCHOR, NOTHING_TO_FULL_SCREEN, }; use crate::app::{App, Focus, HitTarget, RowKey}; use crate::keymap::{Action, KeyChord}; @@ -34,8 +34,6 @@ const NO_FOLD_IN_PANELS: &str = "the panels' pane doesn't fold — ^F full-scree /// What `` ` `` says: a checkout's terminals are rows of the SESSIONS /// column here, not chips over the pane. const NO_PANE_TABS_IN_PANELS: &str = "terminals are rows under TERMINALS in the SESSIONS column"; -/// What `^F` says with nothing in the pane to full-screen. -const NOTHING_TO_FULL_SCREEN: &str = "no session in the pane — j/k onto one, then ^F"; /// A panel key while the PANELS are up — true when it was taken here. Only /// the keys the GRID's own handler owns (`launcher::handle_action`) and @@ -117,11 +115,7 @@ pub(super) fn panel_key(app: &mut App, chord: &KeyChord, out: &mut Vec walk_focus_forward(app, out), PanelKey::FocusTerminal => app.focus = app.next_visible_focus(app.focus), PanelKey::Zoom => { - if app.term.is_some() { - zoom_pane(app, out); - } else { - app.flash = Some(ATTACH_FIRST.into()); - } + zoom_pane(app, out); } PanelKey::ContextMenu => open_row_menu(app), PanelKey::OpenGhosttyTab => open_ghostty_tab(app), @@ -130,9 +124,6 @@ pub(super) fn panel_key(app: &mut App, chord: &KeyChord, out: &mut Vec) /// folds it — `activate::follow_up`, exactly what Space on it does — and /// a click inside the open composer only gives SESSIONS FOCUS. pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { + if !matches!(row, Row::Session(_) | Row::Worktree(_)) { + app.last_session_click = None; + } match row { Row::OpenPrsHeader => toggle_open_prs(app, out), Row::IssuesHeader => toggle_issues(app, out), @@ -347,18 +341,19 @@ pub(super) fn click_row(app: &mut App, row: Row, out: &mut Vec) { select_row(app, row, out); match app.selected_session_row() { Some(row) if row.is_archived_agent() => { + app.last_session_click = None; app.flash = Some(super::AGENT_ARCHIVED.into()); } Some(row) => { let key = match row.sref() { Some(sref) => RowKey::Session(sref), - None => RowKey::Link(row.name().to_string()), + None => RowKey::Link(row.as_link().unwrap().url().to_string()), }; if is_double_click(&mut app.last_session_click, key) { attach_selected(app, out); } } - None => {} + None => app.last_session_click = None, } } } @@ -435,6 +430,7 @@ mod tests { }; use ratatui::backend::TestBackend; use ratatui::Terminal; + use std::time::Duration; /// `seed_tree`'s `demo` (root `main`, session `agent-1`) with a second /// checkout `feat` running `polish-nav`, and a second project `web` @@ -1410,7 +1406,7 @@ mod tests { app.focus = Focus::Sessions; draw(&mut app); key(&mut app, 'z', &mut out); - assert_eq!(app.flash.as_deref(), Some(super::ATTACH_FIRST)); + assert_eq!(app.flash.as_deref(), Some(super::NOTHING_TO_FULL_SCREEN)); assert!(!app.collapsed); let sref = app.selected_session_row().and_then(|r| r.sref()).unwrap(); app.term = Some(AttachedTerm::new(sref, 40, 10)); @@ -2143,4 +2139,428 @@ mod tests { assert_eq!(app.flash.as_deref(), Some(super::super::AGENT_ARCHIVED)); assert!(!app.term_locked); } + + /// Readers can cover a retained attachment. Every route into the pane + /// must keep typing and paste away from that hidden session. + #[test] + fn readers_never_take_input_for_the_retained_session() { + with_config_json("{}", || { + for issue in [false, true] { + for entry in 0..4 { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + press( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + let session = app.term.as_ref().unwrap().sref.clone(); + if issue { + super::super::tests::seed_issues(&mut app, &[(7, "fix login")]); + app.issues_collapsed = false; + } else { + super::super::tests::seed_open_prs(&mut app, &[(7, "fix login")]); + } + let at = app + .worktree_rows() + .iter() + .position(|row| { + if issue { + row.open_issue().is_some() + } else { + row.open_pr().is_some() + } + }) + .unwrap(); + super::select_worktree_row(&mut app, at, &mut out); + app.focus = Focus::Worktrees; + draw(&mut app); + app.pending_attach = + Some((session, std::time::Instant::now() + Duration::from_secs(30))); + out.clear(); + match entry { + 0 => press(&mut app, KeyCode::Tab, KeyModifiers::NONE, &mut out), + 1 => { + let pane = hit_rect(&app, HitTarget::TerminalPane); + mouse_at( + &mut app, + MouseEventKind::Down(MouseButton::Left), + (pane.x + 1, pane.y + 1), + &mut out, + ); + } + 2 => key(&mut app, 'z', &mut out), + _ => press( + &mut app, + KeyCode::Char('f'), + KeyModifiers::CONTROL, + &mut out, + ), + } + assert!( + !app.term_locked, + "reader took the lock: issue={issue}, entry={entry}" + ); + assert!(!app.collapsed, "reader full-screened a hidden attachment"); + assert!(app.pending_attach.is_some()); + if entry <= 1 { + assert_eq!(app.focus, Focus::Terminal); + assert!( + app.term_selection.is_none(), + "reader selected hidden PTY text" + ); + // Drawing a long reader records this count. Set it + // directly so this checks key routing independently + // of markdown wrapping and detail-fetch fixtures. + app.pr_preview_lines = 200; + press(&mut app, KeyCode::PageDown, KeyModifiers::NONE, &mut out); + assert!( + app.pr_preview_scroll > 0, + "focused reader must take paging keys" + ); + } + assert!(!out + .iter() + .any(|r| matches!(r, ClientRequest::Attach { .. }))); + // Also guard a stale lock, before dispatch can flush an + // attachment or route a paste into its hidden PTY. + app.focus = Focus::Terminal; + app.term_locked = true; + super::super::dispatch_terminal_event( + &mut app, + crossterm::event::Event::Paste("secret paste".into()), + &mut out, + ); + key(&mut app, 'x', &mut out); + assert!( + !out.iter().any(|r| matches!( + r, + ClientRequest::Input { .. } | ClientRequest::Attach { .. } + )), + "{out:?}" + ); + assert!(app.pending_attach.is_some()); + assert!(if issue { + app.previewed_issue().is_some() + } else { + app.previewed_pr().is_some() + }); + } + } + }); + } + + /// The same refusal in the GRID: with a reader over the pane, `^F` says + /// so, and neither full-screens nor takes the lock, nor reports it. + #[test] + fn the_grid_refuses_to_full_screen_a_reader() { + with_config_json("{}", || { + for issue in [false, true] { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + press( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + if issue { + super::super::tests::seed_issues(&mut app, &[(7, "fix login")]); + app.issues_collapsed = false; + } else { + super::super::tests::seed_open_prs(&mut app, &[(7, "fix login")]); + } + let at = app + .worktree_rows() + .iter() + .position(|row| { + if issue { + row.open_issue().is_some() + } else { + row.open_pr().is_some() + } + }) + .unwrap(); + super::select_worktree_row(&mut app, at, &mut out); + app.panels = false; + draw(&mut app); + assert!(app.launcher_grid()); + // FOCUS in the pane, a retained attachment under the reader. + app.focus = Focus::Terminal; + assert!(!app.pane_shows_terminal(), "issue={issue}"); + app.flash = None; + assert_eq!( + crate::event_loop::launcher::toggle_full_screen(&mut app, &mut out), + None, + "issue={issue}" + ); + assert_eq!(app.flash.as_deref(), Some(super::NOTHING_TO_FULL_SCREEN)); + assert!(!app.collapsed && !app.term_locked, "issue={issue}"); + } + }); + } + + /// An exited PTY's output and a starting session are still visible + /// terminal surfaces. They may zoom while input remains unavailable. + #[test] + fn exited_and_starting_sessions_can_zoom_and_keep_the_escape_hatch() { + for starting in [false, true] { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + press( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + if starting { + let agent = selected_agent(&app); + app.pending.insert( + 999, + crate::app::PendingIntent::AttachCreated { + focus: true, + placeholder: Some(agent), + }, + ); + assert!(app.pane_shows_placeholder()); + } else { + app.term.as_mut().unwrap().exited = true; + } + app.focus = Focus::Worktrees; + assert!(app.pane_shows_terminal()); + assert!(!app.pane_accepts_input()); + out.clear(); + key(&mut app, 'z', &mut out); + assert!(app.collapsed && app.term_locked); + key(&mut app, 'x', &mut out); + assert!(!out.iter().any(|r| matches!(r, ClientRequest::Input { .. }))); + press( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!(!app.collapsed && !app.term_locked); + assert!(!app.should_quit); + } + } + + #[test] + fn checkout_activation_walks_past_folded_sessions() { + for double_click in [false, true] { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + press( + &mut app, + KeyCode::Char('q'), + KeyModifiers::CONTROL, + &mut out, + ); + app.panels_hidden[2] = true; + app.focus = Focus::Worktrees; + draw(&mut app); + if double_click { + let row = Row::Worktree(app.sel_worktree); + super::click_row(&mut app, row, &mut out); + super::click_row(&mut app, row, &mut out); + } else { + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + } + draw(&mut app); + assert_eq!(app.focus, Focus::Terminal); + assert!(app.term_locked, "a visible live session takes the lock"); + } + } + + #[test] + fn archived_activation_never_attaches_or_locks() { + for full_screen in [false, true] { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + app.show_archived = true; + app.tree + .agents + .iter_mut() + .find(|a| a.id.0 == "b1") + .unwrap() + .archived = true; + draw(&mut app); + app.sel_session = app + .visible_session_rows() + .iter() + .position(|r| r.is_archived_agent()) + .unwrap(); + press( + &mut app, + if full_screen { + KeyCode::Char('f') + } else { + KeyCode::Enter + }, + if full_screen { + KeyModifiers::CONTROL + } else { + KeyModifiers::NONE + }, + &mut out, + ); + assert_eq!(app.flash.as_deref(), Some(super::super::AGENT_ARCHIVED)); + assert!(!app.term_locked); + assert!( + !out.iter() + .any(|r| matches!(r, ClientRequest::Attach { .. })), + "{out:?}" + ); + } + } + + fn quick_launch(app: &App) -> &crate::quick_prompt::QuickLaunch { + let Some(Overlay::Prompt(prompt)) = &app.overlay else { + panic!("no prompt: {:?}", app.overlay); + }; + let PromptKind::QuickPrompt(launch) = &prompt.kind else { + panic!("not a quick prompt: {:?}", prompt.kind); + }; + launch + } + + #[test] + fn quick_prompt_uses_the_visible_empty_checkout_and_can_flip_back() { + for new_worktree in [false, true] { + with_config_json( + &format!( + r#"{{"show_all_worktrees":false,"quick_prompt_new_worktree":{new_worktree}}}"# + ), + || { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + app.show_all_worktrees = false; + app.tree.agents.retain(|a| a.worktree_id.0 != "w2"); + let at = app + .worktree_rows() + .iter() + .position(|r| { + matches!(r, + crate::app::WorktreeRow::Checkout(w) if w.id.0 == "w2") + }) + .unwrap(); + super::select_worktree_row(&mut app, at, &mut out); + app.focus = Focus::Worktrees; + draw(&mut app); + key(&mut app, 'p', &mut out); + assert_eq!(quick_launch(&app).is_new_worktree(), new_worktree); + if !new_worktree { + assert!( + matches!(&quick_launch(&app).target, crate::quick_prompt::QuickTarget::Worktree(id) if id.0 == "w2") + ); + press( + &mut app, + KeyCode::Char('n'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!(quick_launch(&app).is_new_worktree()); + } + press( + &mut app, + KeyCode::Char('n'), + KeyModifiers::CONTROL, + &mut out, + ); + assert!( + matches!(&quick_launch(&app).target, crate::quick_prompt::QuickTarget::Worktree(id) if id.0 == "w2") + ); + }, + ); + } + } + + #[test] + fn quick_prompt_carries_the_selected_pr_or_issue() { + with_config_json("{}", || { + for issue in [false, true] { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + if issue { + super::super::tests::seed_issues(&mut app, &[(7, "fix login")]); + app.issues_collapsed = false; + } else { + super::super::tests::seed_open_prs(&mut app, &[(7, "fix login")]); + } + app.sel_worktree = app + .worktree_rows() + .iter() + .position(|row| { + if issue { + row.open_issue().is_some() + } else { + row.open_pr().is_some() + } + }) + .unwrap(); + app.focus = Focus::Worktrees; + draw(&mut app); + key(&mut app, 'p', &mut out); + let launch = quick_launch(&app); + assert_eq!(launch.issue.is_some(), issue); + assert_eq!(launch.pr.is_some(), !issue); + } + }); + } + + #[test] + fn intervening_clicks_break_a_session_double_click() { + for interruption in 0..4 { + let mut app = panels_app(); + let mut out = Vec::new(); + demo_root(&mut app); + draw(&mut app); + super::click_row(&mut app, Row::Session(0), &mut out); + match interruption { + 0 => { + let project = app.sel_project; + super::click_row(&mut app, Row::Project(project), &mut out); + } + 1 => super::click_row(&mut app, Row::FollowUpBox, &mut out), + 2 => { + let background = hit_rect(&app, HitTarget::PanelBg(Focus::Projects)); + mouse_at( + &mut app, + MouseEventKind::Down(MouseButton::Left), + (background.x + 1, background.bottom() - 1), + &mut out, + ); + } + _ => mouse_at( + &mut app, + MouseEventKind::Down(MouseButton::Left), + (0, 0), + &mut out, + ), + } + super::click_row(&mut app, Row::Session(0), &mut out); + assert!( + !app.term_locked, + "intervening click {interruption} kept the gesture alive" + ); + super::click_row(&mut app, Row::Session(0), &mut out); + assert!(app.term_locked, "two consecutive row clicks still attach"); + } + } } diff --git a/crates/nebula-tui/src/launcher.rs b/crates/nebula-tui/src/launcher.rs index 6fd8e785..101ed163 100644 --- a/crates/nebula-tui/src/launcher.rs +++ b/crates/nebula-tui/src/launcher.rs @@ -1629,7 +1629,12 @@ pub fn target_for(app: &App, project: &ProjectId, new_worktree: bool) -> QuickTa /// `^P`. A checkout still being cut is stepped over to the root. None /// for a project with no usable checkout at all. pub fn launch_checkout(app: &App, project: &ProjectId) -> Option { - cursor_checkout(app) + let checkout = if app.panels_active() { + app.selected_worktree().map(|w| w.id.clone()) + } else { + cursor_checkout(app) + }; + checkout .filter(|id| { app.tree .worktrees From ad07fb1c7a63fa27948ff15de97ba6f0259cebd6 Mon Sep 17 00:00:00 2001 From: David Budac Date: Sun, 4 Oct 2026 15:48:12 +0200 Subject: [PATCH 5/5] Screenshot scenes for the panels - panels: three launches beside PROJECTS | WORKTREES | SESSIONS. - panels-drag / panels-drag-floor: borders dragged, and the pane's floor. - panels-rails / panels-pr-fold: folded columns and the SESSIONS fold. - panels-follow-up / panels-recent-prompts / panels-settings-recent-prompts: the inline follow-up and recent prompts. - panels-restart: the dragged widths after nebula restarts, via the harness's new `RESTART=1`. Co-Authored-By: Claude Opus 5.5 --- scripts/shot/scenes/panels-drag-floor.keys | 22 ++++++++++ .../shot/scenes/panels-drag-floor.setup.sh | 2 + scripts/shot/scenes/panels-drag.keys | 23 +++++++++++ scripts/shot/scenes/panels-drag.setup.sh | 2 + scripts/shot/scenes/panels-follow-up.keys | 16 ++++++++ scripts/shot/scenes/panels-follow-up.setup.sh | 5 +++ scripts/shot/scenes/panels-pr-fold.keys | 25 ++++++++++++ scripts/shot/scenes/panels-pr-fold.setup.sh | 2 + scripts/shot/scenes/panels-rails.keys | 24 +++++++++++ scripts/shot/scenes/panels-rails.setup.sh | 2 + .../shot/scenes/panels-recent-prompts.keys | 19 +++++++++ .../scenes/panels-recent-prompts.setup.sh | 40 +++++++++++++++++++ scripts/shot/scenes/panels-restart.keys | 24 +++++++++++ scripts/shot/scenes/panels-restart.setup.sh | 7 ++++ .../panels-settings-recent-prompts.keys | 22 ++++++++++ .../panels-settings-recent-prompts.setup.sh | 2 + scripts/shot/scenes/panels.keys | 22 ++++++++++ scripts/shot/scenes/panels.setup.sh | 6 +++ scripts/shot/shot.sh | 8 ++++ 19 files changed, 273 insertions(+) create mode 100644 scripts/shot/scenes/panels-drag-floor.keys create mode 100644 scripts/shot/scenes/panels-drag-floor.setup.sh create mode 100644 scripts/shot/scenes/panels-drag.keys create mode 100644 scripts/shot/scenes/panels-drag.setup.sh create mode 100644 scripts/shot/scenes/panels-follow-up.keys create mode 100644 scripts/shot/scenes/panels-follow-up.setup.sh create mode 100644 scripts/shot/scenes/panels-pr-fold.keys create mode 100644 scripts/shot/scenes/panels-pr-fold.setup.sh create mode 100644 scripts/shot/scenes/panels-rails.keys create mode 100644 scripts/shot/scenes/panels-rails.setup.sh create mode 100644 scripts/shot/scenes/panels-recent-prompts.keys create mode 100644 scripts/shot/scenes/panels-recent-prompts.setup.sh create mode 100644 scripts/shot/scenes/panels-restart.keys create mode 100644 scripts/shot/scenes/panels-restart.setup.sh create mode 100644 scripts/shot/scenes/panels-settings-recent-prompts.keys create mode 100644 scripts/shot/scenes/panels-settings-recent-prompts.setup.sh create mode 100644 scripts/shot/scenes/panels.keys create mode 100644 scripts/shot/scenes/panels.setup.sh diff --git a/scripts/shot/scenes/panels-drag-floor.keys b/scripts/shot/scenes/panels-drag-floor.keys new file mode 100644 index 00000000..04c4936c --- /dev/null +++ b/scripts/shot/scenes/panels-drag-floor.keys @@ -0,0 +1,22 @@ +# The PANELS with SESSIONS|pane dragged far past the window's right edge: the column stops where the +# pane is down to MIN_PANE_W (20 cols). +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 74 6 186 6 diff --git a/scripts/shot/scenes/panels-drag-floor.setup.sh b/scripts/shot/scenes/panels-drag-floor.setup.sh new file mode 100644 index 00000000..db50041b --- /dev/null +++ b/scripts/shot/scenes/panels-drag-floor.setup.sh @@ -0,0 +1,2 @@ +# Same demo as panels. +. "$HERE/scenes/panels.setup.sh" diff --git a/scripts/shot/scenes/panels-drag.keys b/scripts/shot/scenes/panels-drag.keys new file mode 100644 index 00000000..4771b719 --- /dev/null +++ b/scripts/shot/scenes/panels-drag.keys @@ -0,0 +1,23 @@ +# The PANELS with two borders dragged: PROJECTS|WORKTREES right from col 20 to 32 (PROJECTS 20 -> 32), +# then SESSIONS|pane left from its new col 86 to 76 (SESSIONS 32 -> 22); the pane takes the difference. +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 20 6 32 6 +drag 86 6 76 6 diff --git a/scripts/shot/scenes/panels-drag.setup.sh b/scripts/shot/scenes/panels-drag.setup.sh new file mode 100644 index 00000000..db50041b --- /dev/null +++ b/scripts/shot/scenes/panels-drag.setup.sh @@ -0,0 +1,2 @@ +# Same demo as panels. +. "$HERE/scenes/panels.setup.sh" diff --git a/scripts/shot/scenes/panels-follow-up.keys b/scripts/shot/scenes/panels-follow-up.keys new file mode 100644 index 00000000..b8096f19 --- /dev/null +++ b/scripts/shot/scenes/panels-follow-up.keys @@ -0,0 +1,16 @@ +# The PANELS' FOLLOW-UP COMPOSER: into the root checkout's SESSIONS column, two quick prompts so the column +# has pills, then Space on the pill the second launch left the cursor on and the next turn typed into it — +# captured before Enter, so the box inside the pill, its flipped `▾` toggle and the pill pushed down below +# it are all in the frame. +Tab +Tab +p +Fix the login redirect +Enter +p +Sort the changelog by date +Enter +F12 +F12 +Space +also add a regression test diff --git a/scripts/shot/scenes/panels-follow-up.setup.sh b/scripts/shot/scenes/panels-follow-up.setup.sh new file mode 100644 index 00000000..b16db4b0 --- /dev/null +++ b/scripts/shot/scenes/panels-follow-up.setup.sh @@ -0,0 +1,5 @@ +# The PANELS over the FOLLOW-UP scene's demo: two sessions that finish quietly, so the pills read as +# idle agents waiting for their next turn and nothing sweeps over the box. +. "$HERE/scenes/follow-up.setup.sh" +printf '{"theme":"%s","layout":"panels","prewarm_agents":false,"prewarm_sessions":false}\n' \ + "${NEBULA_SHOT_THEME:-default}" > "$WORK/data/config.json" diff --git a/scripts/shot/scenes/panels-pr-fold.keys b/scripts/shot/scenes/panels-pr-fold.keys new file mode 100644 index 00000000..761f930d --- /dev/null +++ b/scripts/shot/scenes/panels-pr-fold.keys @@ -0,0 +1,25 @@ +# The PANELS with the WORKTREES cursor on the draft pull request #39: SESSIONS folds to its bare rule +# and the pane reading the pull request takes its width. +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +l +j +j +j diff --git a/scripts/shot/scenes/panels-pr-fold.setup.sh b/scripts/shot/scenes/panels-pr-fold.setup.sh new file mode 100644 index 00000000..db50041b --- /dev/null +++ b/scripts/shot/scenes/panels-pr-fold.setup.sh @@ -0,0 +1,2 @@ +# Same demo as panels. +. "$HERE/scenes/panels.setup.sh" diff --git a/scripts/shot/scenes/panels-rails.keys b/scripts/shot/scenes/panels-rails.keys new file mode 100644 index 00000000..690d05dd --- /dev/null +++ b/scripts/shot/scenes/panels-rails.keys @@ -0,0 +1,24 @@ +# The PANELS with PROJECTS and WORKTREES collapsed to their rails by ⇧P and ⇧B: two one-column rails down the left edge, +# each with its ▶, SESSIONS beside them with the keys, the pane taking the width, and the footer leading with +# ⇧P: show projects ⇧B: show worktrees. +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +P +B diff --git a/scripts/shot/scenes/panels-rails.setup.sh b/scripts/shot/scenes/panels-rails.setup.sh new file mode 100644 index 00000000..db50041b --- /dev/null +++ b/scripts/shot/scenes/panels-rails.setup.sh @@ -0,0 +1,2 @@ +# Same demo as panels. +. "$HERE/scenes/panels.setup.sh" diff --git a/scripts/shot/scenes/panels-recent-prompts.keys b/scripts/shot/scenes/panels-recent-prompts.keys new file mode 100644 index 00000000..0032c78a --- /dev/null +++ b/scripts/shot/scenes/panels-recent-prompts.keys @@ -0,0 +1,19 @@ +# The PANELS' RECENT PROMPTS: three quick prompts on the root checkout from its SESSIONS column; the +# stand-in agent (see the .setup.sh) posts each session's prompts through the hooks, so every session's +# pill grows its RECENT PROMPTS lines under its name, the cursor's on its fill. +Tab +Tab +p +Fix the login redirect +Enter +p +Sort the changelog by date +Enter +p +Profile the daemon startup +Enter +F12 +F12 +F12 +F12 +F12 diff --git a/scripts/shot/scenes/panels-recent-prompts.setup.sh b/scripts/shot/scenes/panels-recent-prompts.setup.sh new file mode 100644 index 00000000..44559688 --- /dev/null +++ b/scripts/shot/scenes/panels-recent-prompts.setup.sh @@ -0,0 +1,40 @@ +# The PANELS (Settings › Appearance › Layout `panels`) with RECENT PROMPTS on, the PREWARM POOL off (so +# every launch is one of the three scripted below), and a stand-in agent that posts a few prompts through +# the HOOK RECEIVER — the same curl the installed hooks run — then titles its row with `nebula rename` and +# behaves like /bin/cat. +mkdir -p "$WORK/data" +cat > "$WORK/data/config.json" <<'JSON' +{"layout": "panels", "recent_prompts": true, "recent_prompts_count": 3, "prewarm_agents": false, "prewarm_sessions": false} +JSON +export NEBULA_SHOT_BIN="$BIN" NEBULA_SHOT_COUNTER="$RUNTIME/launches" +cat > "$RUNTIME/agent" <<'AGENT' +#!/bin/sh +# Stand-in agent for the recent-prompts scene. Launch N tells one of three stories. +n=$(cat "$NEBULA_SHOT_COUNTER" 2>/dev/null || echo 0); n=$((n + 1)); echo "$n" > "$NEBULA_SHOT_COUNTER" +post() { + curl -sS -m 3 -X POST -H "Authorization: Bearer $NEBULA_API_TOKEN" -H 'Content-Type: application/json' \ + -d "$2" "$NEBULA_API_URL/api/hooks/claude?agentId=$NEBULA_AGENT_ID&hookEvent=$1" >/dev/null 2>&1 +} +case "$n" in + 1) title="Login redirect loop" + set -- "Fix the login redirect loop when the session cookie has expired" \ + "Add a regression test for the expired-cookie redirect" \ + "Why does the macOS CI job still time out on the new test?" ;; + 2) title="Changelog by date" + set -- "Sort the changelog entries by date, newest first" \ + "Keep the Unreleased section pinned at the top" ;; + *) title="Daemon startup profile" + set -- "Profile the daemon's startup and find what takes 400ms" \ + "Cache the parsed config instead of re-reading it on every request" \ + "Now write that up in ARCHITECTURE.md" \ + "Squash those into one commit" ;; +esac +for p in "$@"; do + post UserPromptSubmit "{\"session_id\":\"shot-$n\",\"prompt\":\"$p\"}"; sleep 0.15 + post Stop "{\"session_id\":\"shot-$n\"}"; sleep 0.15 +done +"$NEBULA_SHOT_BIN" rename "$title" >/dev/null 2>&1 || true +exec /bin/cat +AGENT +chmod +x "$RUNTIME/agent" +export NEBULA_AGENT_CMD="$RUNTIME/agent" diff --git a/scripts/shot/scenes/panels-restart.keys b/scripts/shot/scenes/panels-restart.keys new file mode 100644 index 00000000..1bdcab32 --- /dev/null +++ b/scripts/shot/scenes/panels-restart.keys @@ -0,0 +1,24 @@ +# The PANELS: three launches from the box into the selected checkout — one keeps running and opens a +# pull request, one finishes, one stops on a question — then PROJECTS | WORKTREES | SESSIONS beside +# the pane, the SESSIONS cursor on the newest launch, live in the pane. +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +drag 20 6 32 6 +drag 86 6 76 6 diff --git a/scripts/shot/scenes/panels-restart.setup.sh b/scripts/shot/scenes/panels-restart.setup.sh new file mode 100644 index 00000000..3bc8f8d2 --- /dev/null +++ b/scripts/shot/scenes/panels-restart.setup.sh @@ -0,0 +1,7 @@ +# The PANELS (Settings › Appearance › Layout `panels`) over the LAUNCHER VIEW scenes' demo: the same +# three projects and the same stand-in agent whose three launches run, finish and stop on a question. +. "$HERE/scenes/launcher.setup.sh" +cat > "$WORK/data/config.json" <<'JSON' +{"prewarm_agents": false, "prewarm_sessions": false, "layout": "panels"} +JSON +RESTART=1 diff --git a/scripts/shot/scenes/panels-settings-recent-prompts.keys b/scripts/shot/scenes/panels-settings-recent-prompts.keys new file mode 100644 index 00000000..9bd67b5d --- /dev/null +++ b/scripts/shot/scenes/panels-settings-recent-prompts.keys @@ -0,0 +1,22 @@ +# Settings › Appearance with Recent prompts switched on under its PANELS LAYOUT header and the cursor on +# its count row. +s +Tab +Tab +j +j +j +j +j +j +j +j +j +j +j +j +j +j +j +Enter +j diff --git a/scripts/shot/scenes/panels-settings-recent-prompts.setup.sh b/scripts/shot/scenes/panels-settings-recent-prompts.setup.sh new file mode 100644 index 00000000..db50041b --- /dev/null +++ b/scripts/shot/scenes/panels-settings-recent-prompts.setup.sh @@ -0,0 +1,2 @@ +# Same demo as panels. +. "$HERE/scenes/panels.setup.sh" diff --git a/scripts/shot/scenes/panels.keys b/scripts/shot/scenes/panels.keys new file mode 100644 index 00000000..211f106a --- /dev/null +++ b/scripts/shot/scenes/panels.keys @@ -0,0 +1,22 @@ +# The PANELS: three launches from the box into the selected checkout — one keeps running and opens a +# pull request, one finishes, one stops on a question — then PROJECTS | WORKTREES | SESSIONS beside +# the pane, the SESSIONS cursor on the newest launch, live in the pane. +p +Fix the login redirect loop when the session cookie has expired +Enter +p +Sort the changelog entries by date, newest first +Enter +p +Profile the daemon's startup and find what takes 400ms +Enter +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 +F12 diff --git a/scripts/shot/scenes/panels.setup.sh b/scripts/shot/scenes/panels.setup.sh new file mode 100644 index 00000000..112680f1 --- /dev/null +++ b/scripts/shot/scenes/panels.setup.sh @@ -0,0 +1,6 @@ +# The PANELS (Settings › Appearance › Layout `panels`) over the LAUNCHER VIEW scenes' demo: the same +# three projects and the same stand-in agent whose three launches run, finish and stop on a question. +. "$HERE/scenes/launcher.setup.sh" +cat > "$WORK/data/config.json" <<'JSON' +{"prewarm_agents": false, "prewarm_sessions": false, "layout": "panels"} +JSON diff --git a/scripts/shot/shot.sh b/scripts/shot/shot.sh index 85b882cc..2f1b10fa 100755 --- a/scripts/shot/shot.sh +++ b/scripts/shot/shot.sh @@ -6,6 +6,10 @@ # repository with two worktrees, drives it inside a private tmux server, and captures the screen as # design-screenshots/.{txt,ansi,png}. Never touches the real daemon or the real data dir. # +# RESTART=1 (or a scene's setup.sh setting it) quits the TUI after the keys and starts a fresh one on the +# same data dir before capturing: a scene for what survives a restart. The screen before the quit is kept as +# design-screenshots/.before-quit.txt. +# # Traps this encodes (learned 2026-08-20 / 2026-08-21): NEBULA_RUNTIME_DIR must be short (the unix # socket path caps at ~104 chars); NEBULA_AGENT_CMD must be set even with no agent (the PREWARM POOL # launches a real claude otherwise); the first exec of a fresh binary can stall on macOS signature @@ -73,6 +77,10 @@ if [ -f "$HERE/scenes/$SCENE.keys" ]; then while IFS= read -r key; do case "$key" in ''|'#'*) continue;; esac; send "$key"; done < "$HERE/scenes/$SCENE.keys" fi for key in ${KEYS:-}; do send "$key"; done +if [ -n "${RESTART:-}" ]; then + sleep 1; $TMUX capture-pane -pN > "$OUT/$SCENE.before-quit.txt"; send C-q; send q; send y; sleep 2 + $TMUX kill-server 2>/dev/null || true; $TMUX new-session -d -x "$COLS" -y "$ROWS" "$BIN"; sleep 4 +fi sleep 1 $TMUX capture-pane -epN > "$OUT/$SCENE.ansi" $TMUX capture-pane -pN > "$OUT/$SCENE.txt"