Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc783778c8 |
@@ -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.
|
||||||
@@ -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.
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
let mut chars = word.chars();
|
let mut chars = word.chars();
|
||||||
@@ -476,4 +511,22 @@ mod tests {
|
|||||||
assert_eq!(command_program("sudo"), "sudo");
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
assert_eq!(command_program(""), "");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ pub enum Action {
|
|||||||
RenameHighlighted,
|
RenameHighlighted,
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
|
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||||
|
/// over the sessions, ranked by the shared matcher.
|
||||||
|
OpenCommandBar,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
ShowHelp,
|
ShowHelp,
|
||||||
Quit,
|
Quit,
|
||||||
@@ -94,6 +97,7 @@ impl Action {
|
|||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
Action::RenameHighlighted => "Rename selected session".into(),
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
|
Action::OpenCommandBar => "Go to a session".into(),
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
Action::Quit => "Quit Maestro".into(),
|
Action::Quit => "Quit Maestro".into(),
|
||||||
@@ -120,6 +124,7 @@ impl Action {
|
|||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
|
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"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]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::OpenCommandBar,
|
||||||
Action::RenameHighlighted,
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::OpenSpawnModal,
|
Action::OpenSpawnModal,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
|
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
use crate::daemon_link::DaemonLink;
|
use crate::daemon_link::DaemonLink;
|
||||||
use crate::draft::{DraftInputResult, DraftPane};
|
use crate::draft::{DraftInputResult, DraftPane};
|
||||||
@@ -78,6 +79,9 @@ pub struct App {
|
|||||||
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
/// same reason the worktree entries are.
|
/// same reason the worktree entries are.
|
||||||
rename_input: TextInput,
|
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<CommandBar>,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -210,6 +214,7 @@ impl App {
|
|||||||
link,
|
link,
|
||||||
http,
|
http,
|
||||||
rename_input: TextInput::default(),
|
rename_input: TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -430,6 +435,32 @@ impl App {
|
|||||||
(OverlayDecision::Consumed, Vec::new())
|
(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) => {
|
Some(OverlayKind::Help) => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
@@ -475,6 +506,7 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
|
Action::OpenCommandBar => self.open_command_bar(),
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
effects.push(Effect::LoadDraft);
|
effects.push(Effect::LoadDraft);
|
||||||
@@ -537,6 +569,10 @@ impl App {
|
|||||||
/// highlight already sitting on the attached session — clears at once.
|
/// highlight already sitting on the attached session — clears at once.
|
||||||
fn refresh_preview(&mut self) {
|
fn refresh_preview(&mut self) {
|
||||||
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
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),
|
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||||
_ => None,
|
_ => 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) {
|
fn clear_preview(&mut self) {
|
||||||
if self.state.preview.take().is_some() {
|
if self.state.preview.take().is_some() {
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
@@ -1222,6 +1277,7 @@ impl App {
|
|||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
rename_input: &self.rename_input,
|
rename_input: &self.rename_input,
|
||||||
|
command_bar: self.command_bar.as_ref(),
|
||||||
link: &link_status,
|
link: &link_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2716,6 +2772,181 @@ mod tests {
|
|||||||
.is_none());
|
.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<chrono::DateTime<Utc>>| 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<_>>(),
|
||||||
|
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]
|
#[test]
|
||||||
fn kill_highlighted_opens_confirm() {
|
fn kill_highlighted_opens_confirm() {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
/// The field that won, and the ranges inside it. `None` for an empty
|
||||||
|
/// query, which matches everything and highlights nothing.
|
||||||
|
pub field: Option<MatchFieldName>,
|
||||||
|
pub ranges: Vec<MatchRange>,
|
||||||
|
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<MatchCandidate>,
|
||||||
|
/// What each candidate opens, parallel to `candidates`.
|
||||||
|
targets: Vec<BarTarget>,
|
||||||
|
/// Whether each candidate's session is dead, parallel to `candidates`.
|
||||||
|
dead: Vec<bool>,
|
||||||
|
rows: Vec<BarRow>,
|
||||||
|
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<BarGroup> {
|
||||||
|
let mut groups: Vec<BarGroup> = 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<BarRow> = {
|
||||||
|
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<Line<'static>> {
|
||||||
|
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<Line<'static>> = 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<MatchFieldName>) -> Option<String> {
|
||||||
|
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<Span<'static>> {
|
||||||
|
let chars: Vec<char> = 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> {
|
||||||
|
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<Uuid> {
|
||||||
|
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<String> = 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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("pageup", "ScrollPanePageUp"),
|
("pageup", "ScrollPanePageUp"),
|
||||||
("pagedown", "ScrollPanePageDown"),
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// 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"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
@@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("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
|
/// 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
|
/// it constantly, so binding it here would make it unreachable. The only way
|
||||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||||
/// the Insert escape with it.
|
/// 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 {
|
impl TuiConfig {
|
||||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod command_bar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemon_link;
|
pub mod daemon_link;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
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
|
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||||
/// restart overlay (`docs/RESTART.md`).
|
/// restart overlay (`docs/RESTART.md`).
|
||||||
pub link: &'a crate::daemon_link::LinkStatus,
|
pub link: &'a crate::daemon_link::LinkStatus,
|
||||||
@@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
}
|
}
|
||||||
map.modal = Some(area);
|
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) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -428,6 +439,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||||
@@ -524,6 +536,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -578,6 +591,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -619,6 +633,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ pub enum OverlayKind {
|
|||||||
/// so this stays comparable.
|
/// so this stays comparable.
|
||||||
Rename(Uuid),
|
Rename(Uuid),
|
||||||
Help,
|
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),
|
Toast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user