diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..796f01f --- /dev/null +++ b/TASK.md @@ -0,0 +1,115 @@ +# Task: Gitea issue #153 — the TUI command bar (action, overlay, session results) + +You are an autonomous worker in a blitz over the **Command bar** milestone of +the `naps62/maestro` Gitea repo (`https://git.naps.pt`, `$GITEA_TOKEN` is in +`~/.env.claude` — `source` it). + +Both of your dependencies are merged and on your base branch. + +## Read first + +1. **`docs/COMMAND-BAR.md`** — the contract. "Opening", "Structure", "Empty + query" and "Keys" are yours. Implement what it says. +2. The issue body: + `curl -sS -H "Authorization: token $GITEA_TOKEN" https://git.naps.pt/api/v1/repos/naps62/maestro/issues/153 | jq -r .body` +3. `CLAUDE.md` — conventions. **TDD by default**; `cargo fmt` and + `cargo clippy --all-targets -- -D warnings` before every commit. +4. What you build on, all merged: + - `crates/maestro-tui/src/matcher.rs` (#147) — the match-and-rank function + and its candidate types. **Do not reimplement matching or reorder its + results.** + - `crates/maestro-protocol` — `Session::last_focused_at` (#145), the + daemon's recency stamp. + - `crates/maestro-tui/src/`: `action.rs`, `keymap.rs`, `config.rs` + (`DEFAULT_NORMAL` / `DEFAULT_INSERT`), `state.rs` (`OverlayKind`), + `overlays.rs`, `app.rs` (`handle_overlay_input` and the dispatcher), + `text_input.rs` (the edit buffer the rename prompt already uses). +5. **`web/src/commandBar.ts`** (#149) — the web bar implementing this same + contract, already merged. Read it, especially `orderSessionsForBar`. Where + the doc leaves room, match what it does; the two clients disagreeing is the + failure this contract exists to prevent. + +## What to build + +- **`Action::OpenCommandBar`** — new variant, with its `parse` arm, `Display` + arm, `help_label`, and dispatcher arm. `Action::parse` and `Display` must + round-trip, as the existing tests require of every variant. +- **Bind `ctrl-k` in both `DEFAULT_NORMAL` and `DEFAULT_INSERT`**, so it opens + from the sidebar and from inside a pane. Because it is an ordinary keymap + entry it stays rebindable, and the help overlay — which builds its lines from + the live keymap — picks it up with no second list. +- **A new `OverlayKind` variant**, routed in `handle_overlay_input` alongside + Spawn, KillConfirm, Rename and Help. Follow how `Rename` keeps its edit + buffer on the app rather than in the enum, so the overlay enum stays + comparable. +- **Empty query lists sessions, most recently focused first**, by + `last_focused_at`. The doc fixes what happens to never-focused and dead + sessions — follow it exactly, and match `orderSessionsForBar`. +- **Typing fuzzy-matches sessions** through `matcher.rs`, with matched ranges + highlighted in the rendered row. Build candidates from the fields the doc's + candidate table names, directory abbreviated as the sidebar shows it. +- **Results render under kind headers**, groups ordered by best-scoring member, + highlight starting on the globally best row. Only sessions exist as a kind + here, but build the grouping so #154 can add commands and directories without + restructuring it. +- **Enter attaches the highlighted session.** Escape closes. Up/down and + `ctrl-p`/`ctrl-n` move the highlight, which does not wrap; headers are never + landed on. + +**Module name**: `palette.rs` is the colour palette. Do not put this there and +do not name your module `palette`. + +## Non-goals — later issues own these, do not build them + +- The `>` and `~` sigils (#154). +- Tab drilling into a session's action page (#155). +- The leader sending a literal Ctrl-K to the pane (#156). + +Build the grouping and query line so those slot in without a rewrite, but do +not implement them. + +## Ground rules + +- You are on `command-bar/153-tui-bar`, cut from `origin/main`. **Merge + `origin/blitz/command-bar` first** (merge, never rebase) — everything you + depend on is there and not on `main`. Merge it again if it moves under you. +- Stay inside `crates/`. Do not touch `web/`, and do not modify `matcher.rs`; + if you believe it is wrong, comment on the issue instead. +- Integration tests use **real tmux** — don't mock it; isolate with a temp + `$HOME` and `TMUX_TMPDIR` the way the existing tests do. `overlays.rs` shows + how the existing overlays are tested against a `TestBackend`. +- Run the suite at most once at the end, through the gate: + `/home/naps62/.claude/skills/tracker-common/scripts/gate.sh -- cargo test --workspace`. + Exit 75 means no slot — a normal outcome, not a failure: say so and push + anyway. Exit 137 is the memory cap, not a test failure. +- Report the **aggregate test count**, not one crate's. +- Commit convention: `feat(maestro): ...`. +- Bugs or problems outside this issue's scope: **comment them on issue #153**, + do not fix them silently. Earlier workers' reports changed the scope of two + later issues before they started. +- Never wait for input. + +## Exit criteria + +Do not end your final reply until ALL of these are true: + +1. `Action::OpenCommandBar` exists, round-trips through `parse`/`Display`, and + has a plain-language `help_label`. +2. `ctrl-k` is bound in both the Normal and Insert default keymaps and opens + the bar from each. +3. Empty query lists sessions by recency, with the doc's rules for + never-focused and dead sessions, matching `orderSessionsForBar`. +4. Typing ranks through `matcher.rs` with ranges highlighted, under kind + headers. +5. Enter attaches; Escape, arrows and `ctrl-p`/`ctrl-n` behave as the doc's key + table says. +6. `cargo fmt` clean, `cargo clippy --all-targets -- -D warnings` clean, suite + green (or the gate returned 75/137 and you said which). +7. Committed on `command-bar/153-tui-bar` and the branch **pushed** to + `origin`. + +Do **not** merge into `blitz/command-bar` and do **not** close the issue — the +orchestrator does both. + +If you catch yourself summarizing progress before those seven are true, you +have stopped too early — keep going. diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 474949d..9aac71b 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String { } } +/// Program name (already basenamed and lowercased) -> the tool id its mark +/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`; +/// recognition is deliberately narrow, so an unrecognised program has no tool. +const BY_PROGRAM: [(&str, &str); 17] = [ + ("claude", "claude"), + ("claude-code", "claude"), + ("codex", "codex"), + ("opencode", "opencode"), + ("gemini", "gemini"), + ("aider", "aider"), + ("cursor", "cursor"), + ("cursor-agent", "cursor"), + ("pi", "pi"), + ("bash", "shell"), + ("zsh", "shell"), + ("sh", "shell"), + ("fish", "shell"), + ("dash", "shell"), + ("ksh", "shell"), + ("nu", "shell"), + ("xonsh", "shell"), +]; + +/// The tool id a command line's icon stands for (`claude`, `codex`, +/// `opencode`, ...), or `None` when the program is not one we have a mark for. +/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name +/// the same tools the web's `splitCommand().tool` does. +pub fn command_tool(command: &str) -> Option<&'static str> { + let program = command_program(command).to_lowercase(); + BY_PROGRAM + .iter() + .find(|(name, _)| *name == program) + .map(|(_, tool)| *tool) +} + /// `FOO=bar`, the shell's own prefix for one command's environment. fn is_env_assignment(word: &str) -> bool { let mut chars = word.chars(); @@ -476,4 +511,22 @@ mod tests { assert_eq!(command_program("sudo"), "sudo"); assert_eq!(command_program(""), ""); } + + /// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same + /// tools the web's `splitCommand().tool` does. + #[test] + fn command_tool_names_the_mark_the_card_shows() { + assert_eq!(command_tool("claude --resume"), Some("claude")); + assert_eq!(command_tool("npx opencode"), Some("opencode")); + assert_eq!(command_tool("/bin/zsh -l"), Some("shell")); + assert_eq!(command_tool("CLAUDE"), Some("claude")); + } + + /// Narrow on purpose: an unrecognised program has no mark, so the + /// candidate has no `tool` field to match rather than a wrong one. + #[test] + fn command_tool_is_absent_for_an_unrecognised_program() { + assert_eq!(command_tool("hx src/main.rs"), None); + assert_eq!(command_tool(""), None); + } } diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index d821a6d..7134a9d 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -53,6 +53,9 @@ pub enum Action { RenameHighlighted, // Chrome + /// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line + /// over the sessions, ranked by the shared matcher. + OpenCommandBar, ToggleSidebar, ShowHelp, Quit, @@ -94,6 +97,7 @@ impl Action { Action::FastForwardSpawn => "Create session with defaults".into(), Action::KillHighlighted => "Kill selected session".into(), Action::RenameHighlighted => "Rename selected session".into(), + Action::OpenCommandBar => "Go to a session".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), Action::Quit => "Quit Maestro".into(), @@ -120,6 +124,7 @@ impl Action { "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted), + "OpenCommandBar" => return Ok(Action::OpenCommandBar), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), @@ -239,10 +244,23 @@ mod tests { ); } + /// The command bar is an ordinary keymap entry, so it must round-trip + /// like every other action (#153). + #[test] + fn open_command_bar_round_trips() { + assert_eq!( + Action::parse("OpenCommandBar").unwrap(), + Action::OpenCommandBar + ); + assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar"); + assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, + Action::OpenCommandBar, Action::RenameHighlighted, Action::Quit, Action::OpenSpawnModal, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index a9a8e4a..089a808 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -23,6 +23,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; +use crate::command_bar::{BarOutcome, BarTarget, CommandBar}; use crate::config::TuiConfig; use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; @@ -78,6 +79,9 @@ pub struct App { /// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the /// same reason the worktree entries are. rename_input: TextInput, + /// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153), + /// kept here for the same reason the rename buffer is. + command_bar: Option, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -210,6 +214,7 @@ impl App { link, http, rename_input: TextInput::default(), + command_bar: None, draft_tx, draft_rx, region: RegionMap::default(), @@ -430,6 +435,32 @@ impl App { (OverlayDecision::Consumed, Vec::new()) } }, + // The bar owns every key while it is up: the keymap must not see + // `j`, `x` or `q` as commands mid-query. + Some(OverlayKind::CommandBar) => { + let outcome = match self.command_bar.as_mut() { + Some(bar) => bar.handle_key(key), + None => BarOutcome::Close, + }; + match outcome { + BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()), + BarOutcome::Close => { + self.close_command_bar(); + (OverlayDecision::Consumed, Vec::new()) + } + BarOutcome::Open(BarTarget::Session(sid)) => { + self.close_command_bar(); + // The same open path the sidebar's own attach uses, so + // a bar attach can never leave the layout in a state a + // highlight-and-enter could not produce. + self.state.sidebar_highlight = Some(SidebarItem::Session(sid)); + ( + OverlayDecision::Consumed, + self.dispatch(Action::AttachHighlighted), + ) + } + } + } Some(OverlayKind::Help) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -475,6 +506,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } + Action::OpenCommandBar => self.open_command_bar(), Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -537,6 +569,10 @@ impl App { /// highlight already sitting on the attached session — clears at once. fn refresh_preview(&mut self) { let wanted = match (self.state.focus, &self.state.sidebar_highlight) { + // The bar is a view over the app, not a place in it: while it is + // up the sidebar highlight behind it previews nothing + // (`docs/COMMAND-BAR.md`, "Focus round-trip"). + _ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None, (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), _ => None, }; @@ -588,6 +624,25 @@ impl App { } } + /// Put the command bar up over whatever is behind it (#153). Opening + /// while a session is previewed discards the preview first, like any + /// other real change (`docs/PREVIEW.md`); focus and mode are left alone, + /// so closing gives the keyboard back to whatever held it. + fn open_command_bar(&mut self) { + let snapshot = self.catalog.snapshot(); + self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir)); + self.pending_preview = None; + self.clear_preview(); + self.state.show_overlay(OverlayKind::CommandBar); + } + + /// Take the bar down, dropping its query. Every exit goes through here so + /// a stale buffer can never outlive the overlay. + fn close_command_bar(&mut self) { + self.command_bar = None; + self.state.dismiss_overlay(); + } + fn clear_preview(&mut self) { if self.state.preview.take().is_some() { self.epoch = self.epoch.wrapping_add(1); @@ -1222,6 +1277,7 @@ impl App { draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, + command_bar: self.command_bar.as_ref(), link: &link_status, }; @@ -2716,6 +2772,181 @@ mod tests { .is_none()); } + // -- command bar (#153) ------------------------------------------------- + + fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let make = |n: u128, name: &str, focused: Option>| Session { + id: Uuid::from_u128(n), + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata: HashMap::from([( + "name".to_string(), + serde_json::Value::String(name.to_string()), + )]), + worktree: None, + status: SessionStatus::Running, + created_at: Utc::now(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: Utc::now(), + current_cwd: None, + blocked_work: None, + last_focused_at: focused, + }; + let older = Utc::now() - chrono::Duration::seconds(60); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![ + make(1, "alpha", Some(older)), + make(2, "bravo", Some(Utc::now())), + ], + }); + (Uuid::from_u128(1), Uuid::from_u128(2)) + } + + /// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar + /// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`). + #[test] + fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() { + let app = make_app(); + assert_eq!( + app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + assert_eq!( + app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + } + + /// The help overlay builds its lines from the live keymap, so the bar + /// appears there with no second list to maintain. + #[test] + fn the_help_overlay_lists_the_command_bar() { + let app = make_app(); + let lines = render_help_lines(&app.cfg); + assert!( + lines + .iter() + .any(|l| l.contains("ctrl-k") && l.contains("Go to a session")), + "{lines:#?}" + ); + } + + #[test] + fn opening_the_bar_lists_sessions_most_recently_focused_first() { + let mut app = make_app(); + let (alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), ""); + assert_eq!( + bar.rows() + .iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + }) + .collect::>(), + vec![bravo, alpha] + ); + } + + /// Opening from Insert mode leaves the keyboard where it was, so closing + /// gives it back — the bar is a view over the app, not a place in it. + #[test] + fn opening_from_insert_mode_leaves_focus_and_mode_alone() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.state.attach_to_focused(Uuid::from_u128(1)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + } + + /// Enter goes through the same open path the sidebar's attach uses. + #[test] + fn enter_attaches_the_highlighted_session() { + let mut app = make_app(); + let (_alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::Attach(bravo)]); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!(app.state.active_session(), Some(bravo)); + } + + /// Down moves the highlight, so Enter opens the second row instead. + #[test] + fn the_highlight_moves_before_enter_attaches() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + app.handle_overlay_input(&InputKey::Down); + let (_, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(effects, vec![Effect::Attach(alpha)]); + assert_eq!(app.state.active_session(), Some(alpha)); + } + + /// Typing goes to the query, not to the keymap: `q` must not quit and `x` + /// must not raise a kill confirm while the bar is up. + #[test] + fn typing_reaches_the_query_rather_than_the_keymap() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + for c in "bra".chars() { + let (decision, _) = app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + assert_eq!(decision, OverlayDecision::Consumed); + } + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), "bra"); + assert_eq!(bar.rows().len(), 1); + } + + /// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a + /// preview and the highlight behind it raises no new one. + #[test] + fn opening_the_bar_discards_a_preview() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.state.sidebar_highlight = Some(SidebarItem::Session(alpha)); + app.state.preview = Some(alpha); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.preview, None); + assert_eq!(app.pending_preview, None); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs new file mode 100644 index 0000000..c2fce8f --- /dev/null +++ b/crates/maestro-tui/src/command_bar.rs @@ -0,0 +1,758 @@ +//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a +//! result list, opened from Normal or Insert mode, closed with Escape. +//! +//! Sessions are the only kind here (#153); the sigils (#154), the session +//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The +//! grouping below is already kind-generic so those slot in without a rewrite. +//! +//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the +//! groups are a rendering of the ranked list, in the order their best-scoring +//! member appears. The web bar (`web/src/commandBar.ts`) implements the same +//! contract; the two must agree. + +use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path}; +use maestro_protocol::{Session, SessionStatus}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; +use uuid::Uuid; + +use crate::input::InputKey; +use crate::matcher::{ + match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate, +}; +use crate::overlays::centered; +use crate::palette; +use crate::text_input::TextInput; + +/// What Enter on a row does. Only sessions exist today; `>` commands and `~` +/// directories (#154) become further variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarTarget { + Session(Uuid), +} + +/// One row of the result list, already resolved to what it renders and what it +/// opens. Headers are labels, not rows, so they are never in this list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarRow { + pub kind: CandidateKind, + pub target: BarTarget, + /// Primary text — the name as the sidebar shows it. + pub name: String, + /// Secondary text: the field the match was found in when that was not the + /// name, so a session found through its directory says so. + pub meta: Option, + /// The field that won, and the ranges inside it. `None` for an empty + /// query, which matches everything and highlights nothing. + pub field: Option, + pub ranges: Vec, + pub dead: bool, +} + +impl BarRow { + /// Whether the highlight belongs on [`Self::meta`] rather than the name. + fn meta_is_matched(&self) -> bool { + !matches!(self.field, None | Some(MatchFieldName::Name)) + } +} + +/// One kind's slice of the ranked list, as a header plus the rows under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarGroup { + pub kind: CandidateKind, + pub title: &'static str, + /// Index of this group's first row in [`CommandBar::rows`]. + pub start: usize, + pub len: usize, +} + +fn kind_title(kind: CandidateKind) -> &'static str { + match kind { + CandidateKind::Session => "Sessions", + CandidateKind::Command => "Commands", + CandidateKind::Directory => "Directories", + } +} + +/// What a keystroke asked the bar to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarOutcome { + /// The bar stays up. + Stay, + /// Escape: close and give the keyboard back. + Close, + /// Enter on a row: close and open what it points at. + Open(BarTarget), +} + +/// The empty-query order (`docs/COMMAND-BAR.md`, "Empty query"): most recently +/// focused first; a session that has never been focused sorts after every +/// session that has, most recently created first among those; and a dead +/// session sorts after every live one regardless of recency, because a session +/// you cannot attach to is not where you just were. +/// +/// This is also the input order for a non-empty query, which is what the +/// matcher's last tie-break preserves. Mirrors the web's +/// `orderSessionsForBar`. +pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { + let mut ordered: Vec<&Session> = sessions.iter().collect(); + ordered.sort_by(|a, b| { + let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); + dead(a) + .cmp(&dead(b)) + .then_with(|| { + a.last_focused_at + .is_none() + .cmp(&b.last_focused_at.is_none()) + }) + .then_with(|| match (a.last_focused_at, b.last_focused_at) { + (Some(x), Some(y)) => y.cmp(&x), + _ => std::cmp::Ordering::Equal, + }) + .then_with(|| b.created_at.cmp(&a.created_at)) + }); + ordered +} + +/// One session's candidate: the fields `docs/COMMAND-BAR.md`'s candidate table +/// names, with the directory abbreviated the way the sidebar shows it. +pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate { + SessionCandidate { + name: crate::sidebar::name_for(session), + directory: session_tree_path(session).map(|path| abbreviate_dir(&path, home_dir)), + command: (!session.command.is_empty()).then(|| session.command.clone()), + tool: command_tool(&session.command).map(str::to_string), + } +} + +/// The bar's state while it is up: the query buffer, the candidates it was +/// opened over, the rows the current query produces, and the highlight. +#[derive(Debug)] +pub struct CommandBar { + input: TextInput, + /// Matcher input, in the empty-query order. Never reordered — the matcher's + /// last tie-break is what keeps that order meaningful. + candidates: Vec, + /// What each candidate opens, parallel to `candidates`. + targets: Vec, + /// Whether each candidate's session is dead, parallel to `candidates`. + dead: Vec, + rows: Vec, + highlight: usize, +} + +impl CommandBar { + /// Open the bar over the sessions the catalog has right now. + pub fn new(sessions: &[Session], home_dir: &str) -> Self { + let ordered = order_sessions_for_bar(sessions); + let candidates = ordered + .iter() + .map(|s| MatchCandidate::Session(session_candidate(s, home_dir))) + .collect(); + let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect(); + let dead = ordered + .iter() + .map(|s| s.status == SessionStatus::Dead) + .collect(); + let mut bar = Self { + input: TextInput::default(), + candidates, + targets, + dead, + rows: Vec::new(), + highlight: 0, + }; + bar.recompute(); + bar + } + + pub fn query(&self) -> &str { + self.input.value() + } + + pub fn rows(&self) -> &[BarRow] { + &self.rows + } + + pub fn highlight(&self) -> usize { + self.highlight + } + + /// The row Enter would open. + pub fn selected(&self) -> Option<&BarRow> { + self.rows.get(self.highlight) + } + + /// Group the ranked rows by kind for rendering. Grouping never reorders: + /// a group holds its matches in rank order, and groups appear in the order + /// their best-scoring member does — so the first row under the first + /// header is the globally best result. + pub fn groups(&self) -> Vec { + let mut groups: Vec = Vec::new(); + for (index, row) in self.rows.iter().enumerate() { + match groups.iter_mut().find(|g| g.kind == row.kind) { + Some(group) => group.len += 1, + None => groups.push(BarGroup { + kind: row.kind, + title: kind_title(row.kind), + start: index, + len: 1, + }), + } + } + groups + } + + /// Move the highlight. It does not wrap: it stops at the first and last + /// row. + pub fn move_highlight(&mut self, delta: isize) { + if self.rows.is_empty() { + self.highlight = 0; + return; + } + let last = self.rows.len() as isize - 1; + let next = (self.highlight as isize + delta).clamp(0, last); + self.highlight = next as usize; + } + + /// Feed the bar a key. Everything the key table (`docs/COMMAND-BAR.md`, + /// "Keys") does not claim is ordinary editing. + pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { + match key { + InputKey::Esc => BarOutcome::Close, + InputKey::Enter => match self.selected() { + Some(row) => BarOutcome::Open(row.target), + // Nothing to open: the bar stays up rather than closing on a + // key that did nothing. + None => BarOutcome::Stay, + }, + InputKey::Up + | InputKey::Char { + c: 'p', ctrl: true, .. + } => { + self.move_highlight(-1); + BarOutcome::Stay + } + InputKey::Down + | InputKey::Char { + c: 'n', ctrl: true, .. + } => { + self.move_highlight(1); + BarOutcome::Stay + } + // Tab drills into the highlighted session's action page (#155). + // Until then it is inert rather than a literal tab in the query. + InputKey::Tab | InputKey::BackTab => BarOutcome::Stay, + other => { + let before = self.input.value().to_string(); + self.input.handle_key(other); + if self.input.value() != before { + self.recompute(); + } + BarOutcome::Stay + } + } + } + + /// Re-rank for the current query. The highlight starts on the globally + /// best row every time the list changes: an old index would point at a + /// different result. + fn recompute(&mut self) { + let rows: Vec = { + let results = match_and_rank(self.input.value(), &self.candidates); + results + .iter() + .map(|result| { + let index = self + .candidates + .iter() + .position(|c| std::ptr::eq(c, result.candidate)) + .expect("every result borrows one of this bar's candidates"); + let MatchCandidate::Session(session) = result.candidate else { + unreachable!("sessions are the only kind the bar builds (#153)") + }; + BarRow { + kind: CandidateKind::Session, + target: self.targets[index], + name: session.name.clone(), + meta: meta_text(session, result.field), + field: result.field, + ranges: result.ranges.clone(), + dead: self.dead[index], + } + }) + .collect() + }; + self.rows = rows; + self.highlight = 0; + } + + /// Draw the bar and return where the terminal cursor belongs. + pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> { + let pal = palette::active(); + let height = parent.height.saturating_sub(4).clamp(5, 20); + let area = centered(parent, 72, height); + f.render_widget(Clear, area); + let block = Block::default() + .title(" go to ") + .borders(Borders::ALL) + .style(pal.card.base()) + .border_style(pal.border_style(true)); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height < 3 || inner.width == 0 { + return None; + } + let row = |offset: u16| Rect { + x: inner.x, + y: inner.y + offset, + width: inner.width, + height: 1, + }; + + // Query line, then the results, then the key hint on the last row. + let query = row(0); + let col = self + .input + .render(query, f.buffer_mut(), pal.card.text_style()); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "[enter] open [esc] close", + pal.card.dim_style(), + ))), + row(inner.height - 1), + ); + + let list_height = inner.height.saturating_sub(2) as usize; + if list_height > 0 { + for (offset, line) in self.visible_lines(list_height).into_iter().enumerate() { + f.render_widget(Paragraph::new(line), row(1 + offset as u16)); + } + } + Some((col, query.y)) + } + + /// The result lines that fit in `height`, scrolled so the highlighted row + /// is on screen. + fn visible_lines(&self, height: usize) -> Vec> { + let pal = palette::active(); + if self.rows.is_empty() { + return vec![Line::from(Span::styled( + "No matches", + pal.card.muted_style(), + ))]; + } + // Headers and rows, in one list, remembering which line the highlight + // sits on so the scroll can keep it visible. + let mut lines: Vec> = Vec::new(); + let mut highlight_line = 0usize; + for group in self.groups() { + lines.push(Line::from(Span::styled( + group.title.to_string(), + pal.group_row_style(), + ))); + for index in group.start..group.start + group.len { + if index == self.highlight { + highlight_line = lines.len(); + } + lines.push(self.row_line(index)); + } + } + let scroll = highlight_line.saturating_sub(height.saturating_sub(1)); + lines.into_iter().skip(scroll).take(height).collect() + } + + fn row_line(&self, index: usize) -> Line<'static> { + let pal = palette::active(); + let row = &self.rows[index]; + let selected = index == self.highlight; + let surface = pal.surface(selected); + let base = if row.dead { + surface.dim_style() + } else { + surface.text_style() + }; + let hit = base.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + + let mut spans = vec![Span::styled(if selected { "› " } else { " " }, base)]; + let name_ranges: &[MatchRange] = if row.meta_is_matched() { + &[] + } else { + &row.ranges + }; + spans.extend(highlighted_spans(&row.name, name_ranges, base, hit)); + if let Some(meta) = &row.meta { + let meta_style = surface.muted_style(); + let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + spans.push(Span::styled(" ", base)); + let meta_ranges: &[MatchRange] = if row.meta_is_matched() { + &row.ranges + } else { + &[] + }; + spans.extend(highlighted_spans(meta, meta_ranges, meta_style, meta_hit)); + } + Line::from(spans) + } +} + +/// The secondary line under a row's name: the field the match was found in +/// when that was not the name, so a session found through its directory says +/// so. Mirrors the web's `metaText`. +fn meta_text(session: &SessionCandidate, field: Option) -> Option { + match field { + Some(MatchFieldName::Directory) => session.directory.clone(), + Some(MatchFieldName::Command) => session.command.clone(), + Some(MatchFieldName::Tool) => session.tool.clone(), + _ => session.directory.clone(), + } +} + +/// Split `text` into styled spans, with the matcher's `[start, end)` character +/// ranges carrying `hit` and everything else `base`. +fn highlighted_spans( + text: &str, + ranges: &[MatchRange], + base: Style, + hit: Style, +) -> Vec> { + let chars: Vec = text.chars().collect(); + let slice = |from: usize, to: usize| -> String { + chars[from.min(chars.len())..to.min(chars.len())] + .iter() + .collect() + }; + let mut spans = Vec::new(); + let mut cursor = 0usize; + for &(start, end) in ranges { + if start > cursor { + spans.push(Span::styled(slice(cursor, start), base)); + } + spans.push(Span::styled(slice(start, end), hit)); + cursor = end; + } + if cursor < chars.len() { + spans.push(Span::styled(slice(cursor, chars.len()), base)); + } + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::collections::HashMap; + use std::path::PathBuf; + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap() + } + + fn session(n: u128, command: &str) -> Session { + Session { + id: Uuid::from_u128(n), + cwd: PathBuf::from("/home/me/dev/api"), + command: command.to_string(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + status: SessionStatus::Running, + created_at: at(0), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: at(0), + activity_since: at(0), + current_cwd: None, + blocked_work: None, + last_focused_at: None, + } + } + + fn named(n: u128, name: &str) -> Session { + let mut s = session(n, "bash"); + s.metadata + .insert("name".into(), serde_json::Value::String(name.to_string())); + s + } + + fn ids(rows: &[BarRow]) -> Vec { + rows.iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + }) + .collect() + } + + fn typed(bar: &mut CommandBar, text: &str) { + for c in text.chars() { + bar.handle_key(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + } + } + + #[test] + fn empty_query_lists_sessions_most_recently_focused_first() { + let mut a = named(1, "alpha"); + a.last_focused_at = Some(at(10)); + let mut b = named(2, "bravo"); + b.last_focused_at = Some(at(30)); + let mut c = named(3, "charlie"); + c.last_focused_at = Some(at(20)); + + let bar = CommandBar::new(&[a, b, c], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)] + ); + // Every row matches with no ranges, so nothing is highlighted inside. + assert!(bar.rows().iter().all(|r| r.ranges.is_empty())); + } + + #[test] + fn never_focused_sessions_sort_after_focused_ones_newest_first() { + let mut focused = named(1, "focused"); + focused.last_focused_at = Some(at(5)); + let mut older = named(2, "older"); + older.created_at = at(10); + let mut newer = named(3, "newer"); + newer.created_at = at(20); + + let bar = CommandBar::new(&[older, newer, focused], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)] + ); + } + + /// A session you cannot attach to is not where you just were. + #[test] + fn dead_sessions_sort_after_live_ones_however_recent() { + let mut dead = named(1, "dead"); + dead.status = SessionStatus::Dead; + dead.last_focused_at = Some(at(99)); + let live = named(2, "live"); + + let bar = CommandBar::new(&[dead, live], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(1)] + ); + assert!(bar.rows()[1].dead); + } + + #[test] + fn typing_ranks_through_the_matcher_and_returns_ranges() { + let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me"); + typed(&mut bar, "ws"); + // F9: the `s` after `-` earns the boundary bonus. + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + assert_eq!(bar.rows()[0].ranges, vec![(0, 1), (4, 5)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Name)); + } + + #[test] + fn a_query_matching_nothing_leaves_no_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + assert!(bar.rows().is_empty()); + assert!(bar.selected().is_none()); + } + + /// A session found through its directory highlights inside the directory, + /// which is also what the row's meta text shows. + #[test] + fn a_directory_match_highlights_in_the_meta_text() { + let mut worker = named(1, "worker"); + worker.cwd = PathBuf::from("/home/me/dev/api"); + let mut bar = CommandBar::new(&[worker], "/home/me"); + typed(&mut bar, "api"); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory)); + assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api")); + assert_eq!(bar.rows()[0].ranges, vec![(6, 9)]); + } + + /// The candidate's `tool` is the mark the card's icon stands for, so + /// `claude` reaches a session whose name says nothing about it. + #[test] + fn the_tool_field_is_a_candidate_field() { + let mut nameless = named(1, "nameless"); + nameless.command = "claude --resume".into(); + let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me"); + typed(&mut bar, "claude"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool)); + } + + #[test] + fn results_group_under_kind_headers_with_the_best_row_first() { + let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + let groups = bar.groups(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].title, "Sessions"); + assert_eq!(groups[0].kind, CandidateKind::Session); + assert_eq!((groups[0].start, groups[0].len), (0, 2)); + // The highlight starts on the globally best row. + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() { + let mut bar = CommandBar::new( + &[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")], + "/home/me", + ); + assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Char { + c: 'n', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 2); + // Stops at the last row rather than wrapping to the first. + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 2); + bar.handle_key(&InputKey::Char { + c: 'p', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Up); + bar.handle_key(&InputKey::Up); + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn enter_opens_the_highlighted_session_and_escape_closes() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Session(Uuid::from_u128(2))) + ); + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close); + } + + #[test] + fn enter_with_no_results_leaves_the_bar_up() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay); + } + + #[test] + fn editing_the_query_re_ranks_and_resets_the_highlight() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 1); + typed(&mut bar, "alp"); + assert_eq!(bar.query(), "alp"); + assert_eq!(bar.highlight(), 0); + bar.handle_key(&InputKey::Backspace); + assert_eq!(bar.query(), "al"); + assert_eq!(bar.rows().len(), 2); + } + + /// Backspacing back to an empty query is not a special key: the list + /// becomes the recency order again. + #[test] + fn backspacing_to_an_empty_query_restores_the_recency_order() { + let mut recent = named(1, "alpha"); + recent.last_focused_at = Some(at(50)); + let older = named(2, "alpine"); + let mut bar = CommandBar::new(&[older, recent], "/home/me"); + typed(&mut bar, "alpi"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]); + for _ in 0..4 { + bar.handle_key(&InputKey::Backspace); + } + assert_eq!(bar.query(), ""); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + } + + fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_the_query_the_headers_and_the_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + typed(&mut bar, "alp"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("go to"), "{rendered}"); + assert!(rendered.contains("alp"), "{rendered}"); + assert!(rendered.contains("Sessions"), "{rendered}"); + assert!(rendered.contains("alpha"), "{rendered}"); + assert!(rendered.contains("alpine"), "{rendered}"); + assert!(rendered.contains("[esc] close"), "{rendered}"); + } + + #[test] + fn renders_a_no_results_state() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("No matches"), "{rendered}"); + } + + /// The matched characters are drawn bold, so the row shows why it matched. + #[test] + fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() { + let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me"); + typed(&mut bar, "ws"); + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut bold: Vec = Vec::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + if cell.style().add_modifier.contains(Modifier::BOLD) + && cell.style().add_modifier.contains(Modifier::UNDERLINED) + { + bold.push(cell.symbol().to_string()); + } + } + } + assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); + } +} diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index fe9a4b8..507024f 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("pageup", "ScrollPanePageUp"), ("pagedown", "ScrollPanePageDown"), // Chrome + // The command bar (#153). An ordinary entry, so it is rebindable and the + // help overlay picks it up from the live keymap. + ("ctrl-k", "OpenCommandBar"), ("ctrl-w b", "ToggleSidebar"), ("?", "ShowHelp"), ("ctrl-w q", "Quit"), @@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ]; -/// Default Insert-mode bindings — deliberately empty. Every key typed in +/// Default Insert-mode bindings — almost empty on purpose. Every key typed in /// Insert mode belongs to the session, `esc` included: full-screen apps read /// it constantly, so binding it here would make it unreachable. The only way /// back to the sidebar is the leader (`LeaveInsert`), which /// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds /// the Insert escape with it. -const DEFAULT_INSERT: &[(&str, &str)] = &[]; +/// +/// The one exception is the command bar (#153): `ctrl-k` opens it from inside +/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's +/// kill-to-end-of-line is given back through the leader (#156). +const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")]; impl TuiConfig { /// Build a runtime config from a [`TuiSection`] (typically the `tui` diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 80745b7..55c99f4 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -6,6 +6,7 @@ pub mod action; pub mod app; pub mod catalog; pub mod cli; +pub mod command_bar; pub mod config; pub mod daemon_link; pub mod draft; diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 5ecf71b..7d697f3 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -86,6 +86,9 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Edit buffer behind the rename prompt (#125). pub rename_input: &'a crate::text_input::TextInput, + /// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is + /// up. + pub command_bar: Option<&'a crate::command_bar::CommandBar>, /// Liveness + release of the daemon, for the status bar's indicator and the /// restart overlay (`docs/RESTART.md`). pub link: &'a crate::daemon_link::LinkStatus, @@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { } map.modal = Some(area); } + Some(OverlayKind::CommandBar) => { + if let Some(bar) = inputs.command_bar { + if let Some((x, y)) = bar.render(f, area) { + f.set_cursor_position((x, y)); + } + map.modal = Some(area); + } + } Some(OverlayKind::Help) => { inputs.help.render(f, area); map.modal = Some(area); @@ -428,6 +439,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link, }; let backend = ratatui::backend::TestBackend::new(120, 24); @@ -524,6 +536,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; @@ -578,6 +591,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -619,6 +633,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 6614cec..4487e0e 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -52,6 +52,10 @@ pub enum OverlayKind { /// so this stays comparable. Rename(Uuid), Help, + /// The `ctrl-k` command bar (#153). Its query buffer and result list live + /// on `App` as [`crate::command_bar::CommandBar`], for the same reason the + /// rename buffer does — so this stays comparable. + CommandBar, Toast(String), }