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, 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