Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc783778c8 | ||
|
|
83741d7203 | ||
|
|
b618b7dd27 | ||
|
|
f37ff681ea | ||
|
|
8dde63a2ab | ||
|
|
1fe2ae6575 | ||
|
|
5df23b8714 | ||
|
|
46a000aa68 | ||
|
|
b46ba9dd26 | ||
|
|
cd37ac5d6d | ||
|
|
7a3287f231 |
@@ -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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CommandBar>,
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
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);
|
||||
@@ -1179,6 +1234,10 @@ impl App {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight.clone(),
|
||||
attached,
|
||||
// The active tab's session, not the displayed one: a preview is
|
||||
// shown in the pane but never becomes what the operator picked,
|
||||
// so it must not move this fill (`docs/PREVIEW.md`).
|
||||
pane_session: self.state.active_session(),
|
||||
};
|
||||
let shown_sid = self.state.displayed_session();
|
||||
let previewing = self.state.preview.is_some();
|
||||
@@ -1218,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,
|
||||
};
|
||||
|
||||
@@ -1269,6 +1329,7 @@ fn visible_sidebar_order(
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
}
|
||||
.rendered_items_with_collapsed(collapsed)
|
||||
}
|
||||
@@ -2711,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<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]
|
||||
fn kill_highlighted_opens_confirm() {
|
||||
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"),
|
||||
("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`
|
||||
|
||||
@@ -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;
|
||||
@@ -13,6 +14,7 @@ pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod matcher;
|
||||
pub mod overlays;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
//! Pure match-and-rank function for the `ctrl-k` command bar.
|
||||
//!
|
||||
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
|
||||
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
|
||||
//! and scores as `web/src/matcher.ts` for the same input.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CandidateKind {
|
||||
Session,
|
||||
Command,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MatchFieldName {
|
||||
Name,
|
||||
Directory,
|
||||
Command,
|
||||
Tool,
|
||||
Label,
|
||||
Path,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionCandidate {
|
||||
pub name: String,
|
||||
pub directory: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub tool: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandCandidate {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirectoryCandidate {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MatchCandidate {
|
||||
Session(SessionCandidate),
|
||||
Command(CommandCandidate),
|
||||
Directory(DirectoryCandidate),
|
||||
}
|
||||
|
||||
pub type MatchRange = (usize, usize);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MatchResult<'c> {
|
||||
pub candidate: &'c MatchCandidate,
|
||||
pub score: i32,
|
||||
pub field: Option<MatchFieldName>,
|
||||
pub ranges: Vec<MatchRange>,
|
||||
}
|
||||
|
||||
struct Field<'c> {
|
||||
name: MatchFieldName,
|
||||
text: &'c str,
|
||||
weight: i32,
|
||||
}
|
||||
|
||||
impl MatchCandidate {
|
||||
fn kind(&self) -> CandidateKind {
|
||||
match self {
|
||||
MatchCandidate::Session(_) => CandidateKind::Session,
|
||||
MatchCandidate::Command(_) => CandidateKind::Command,
|
||||
MatchCandidate::Directory(_) => CandidateKind::Directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<Field<'_>> {
|
||||
match self {
|
||||
MatchCandidate::Session(session) => {
|
||||
let mut fields = vec![Field {
|
||||
name: MatchFieldName::Name,
|
||||
text: &session.name,
|
||||
weight: 0,
|
||||
}];
|
||||
if let Some(directory) = &session.directory {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Directory,
|
||||
text: directory,
|
||||
weight: -24,
|
||||
});
|
||||
}
|
||||
if let Some(command) = &session.command {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Command,
|
||||
text: command,
|
||||
weight: -32,
|
||||
});
|
||||
}
|
||||
if let Some(tool) = &session.tool {
|
||||
fields.push(Field {
|
||||
name: MatchFieldName::Tool,
|
||||
text: tool,
|
||||
weight: -40,
|
||||
});
|
||||
}
|
||||
fields
|
||||
}
|
||||
MatchCandidate::Command(command) => vec![Field {
|
||||
name: MatchFieldName::Label,
|
||||
text: &command.label,
|
||||
weight: 0,
|
||||
}],
|
||||
MatchCandidate::Directory(directory) => vec![Field {
|
||||
name: MatchFieldName::Path,
|
||||
text: &directory.path,
|
||||
weight: 0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_order(kind: CandidateKind) -> u8 {
|
||||
match kind {
|
||||
CandidateKind::Session => 0,
|
||||
CandidateKind::Command => 1,
|
||||
CandidateKind::Directory => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_char(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
fn lower_chars(chars: &[char]) -> Vec<char> {
|
||||
chars.iter().copied().map(lower_char).collect()
|
||||
}
|
||||
|
||||
fn normalize_query(query: &str) -> Vec<char> {
|
||||
query
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
|
||||
|
||||
fn boundary_bonus(text: &[char], position: usize) -> i32 {
|
||||
if position == 0 {
|
||||
return 18;
|
||||
}
|
||||
|
||||
let previous = text[position - 1];
|
||||
if previous == ' ' || SEPARATORS.contains(&previous) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
let current = text[position];
|
||||
if current.is_uppercase() && previous.is_lowercase() {
|
||||
return 12;
|
||||
}
|
||||
|
||||
let is_digit = current.is_ascii_digit();
|
||||
let previous_is_digit = previous.is_ascii_digit();
|
||||
if is_digit && !previous_is_digit {
|
||||
return 12;
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
|
||||
let gap = position as isize - previous_position - 1;
|
||||
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Embedding {
|
||||
score: i32,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
|
||||
let Some(incumbent) = incumbent else {
|
||||
return true;
|
||||
};
|
||||
if candidate.score != incumbent.score {
|
||||
return candidate.score > incumbent.score;
|
||||
}
|
||||
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
|
||||
if c != i {
|
||||
return c < i;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The dynamic program over `(query index, text position)` described in
|
||||
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
|
||||
/// `mtu` against `maestro-tui`), so every reachable position at each query
|
||||
/// index carries its best score and position vector forward.
|
||||
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
|
||||
let lower_text = lower_chars(text);
|
||||
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (query_index, &qc) in query.iter().enumerate() {
|
||||
let mut states: HashMap<usize, Embedding> = HashMap::new();
|
||||
|
||||
for (position, &text_char) in lower_text.iter().enumerate() {
|
||||
if text_char != qc {
|
||||
continue;
|
||||
}
|
||||
|
||||
if query_index == 0 {
|
||||
states.insert(
|
||||
position,
|
||||
Embedding {
|
||||
score: contribution(text, position, -1),
|
||||
positions: vec![position],
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (&previous_position, previous) in previous_states.iter() {
|
||||
if previous_position >= position {
|
||||
continue;
|
||||
}
|
||||
let mut positions = previous.positions.clone();
|
||||
positions.push(position);
|
||||
let candidate = Embedding {
|
||||
score: previous.score
|
||||
+ contribution(text, position, previous_position as isize),
|
||||
positions,
|
||||
};
|
||||
if is_better_embedding(&candidate, states.get(&position)) {
|
||||
states.insert(position, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if states.is_empty() {
|
||||
return None;
|
||||
}
|
||||
previous_states = states;
|
||||
}
|
||||
|
||||
let mut best: Option<Embedding> = None;
|
||||
for embedding in previous_states.into_values() {
|
||||
if is_better_embedding(&embedding, best.as_ref()) {
|
||||
best = Some(embedding);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
|
||||
let mut ranges: Vec<MatchRange> = Vec::new();
|
||||
for &position in positions {
|
||||
if let Some(last) = ranges.last_mut() {
|
||||
if last.1 == position {
|
||||
last.1 = position + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ranges.push((position, position + 1));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
fn compact(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.map(lower_char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Ranked<'c> {
|
||||
candidate: &'c MatchCandidate,
|
||||
score: i32,
|
||||
field: Option<MatchFieldName>,
|
||||
ranges: Vec<MatchRange>,
|
||||
matched_text: String,
|
||||
input_index: usize,
|
||||
}
|
||||
|
||||
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
|
||||
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
|
||||
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
|
||||
let normalized_query = normalize_query(query);
|
||||
|
||||
if normalized_query.is_empty() {
|
||||
return candidates
|
||||
.iter()
|
||||
.map(|candidate| MatchResult {
|
||||
candidate,
|
||||
score: 0,
|
||||
field: None,
|
||||
ranges: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<Ranked<'c>> = Vec::new();
|
||||
|
||||
for (input_index, candidate) in candidates.iter().enumerate() {
|
||||
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
|
||||
|
||||
for field in candidate.fields() {
|
||||
let text_chars: Vec<char> = field.text.chars().collect();
|
||||
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let compact_text = compact(field.text);
|
||||
let normalized_query_str: String = normalized_query.iter().collect();
|
||||
let adjustment = if compact_text == normalized_query_str {
|
||||
64
|
||||
} else if compact_text.starts_with(&normalized_query_str) {
|
||||
32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let score = embedding.score + adjustment + field.weight;
|
||||
|
||||
let is_better = match &best {
|
||||
None => true,
|
||||
Some((_, _, best_score, _)) => score > *best_score,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((field.name, field.text, score, embedding.positions));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((field_name, field_text, score, positions)) = best {
|
||||
results.push(Ranked {
|
||||
candidate,
|
||||
score,
|
||||
field: Some(field_name),
|
||||
ranges: to_ranges(&positions),
|
||||
matched_text: field_text.to_string(),
|
||||
input_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.cmp(&a.score)
|
||||
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
|
||||
.then_with(|| {
|
||||
a.matched_text
|
||||
.chars()
|
||||
.count()
|
||||
.cmp(&b.matched_text.chars().count())
|
||||
})
|
||||
.then_with(|| a.matched_text.cmp(&b.matched_text))
|
||||
.then_with(|| a.input_index.cmp(&b.input_index))
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|ranked| MatchResult {
|
||||
candidate: ranked.candidate,
|
||||
score: ranked.score,
|
||||
field: ranked.field,
|
||||
ranges: ranked.ranges,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session(
|
||||
name: &str,
|
||||
directory: Option<&str>,
|
||||
command: Option<&str>,
|
||||
tool: Option<&str>,
|
||||
) -> MatchCandidate {
|
||||
MatchCandidate::Session(SessionCandidate {
|
||||
name: name.to_string(),
|
||||
directory: directory.map(str::to_string),
|
||||
command: command.map(str::to_string),
|
||||
tool: tool.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
fn command(label: &str) -> MatchCandidate {
|
||||
MatchCandidate::Command(CommandCandidate {
|
||||
label: label.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn directory(path: &str) -> MatchCandidate {
|
||||
MatchCandidate::Directory(DirectoryCandidate {
|
||||
path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn label(candidate: &MatchCandidate) -> String {
|
||||
match candidate {
|
||||
MatchCandidate::Session(s) => format!("session:{}", s.name),
|
||||
MatchCandidate::Command(c) => format!("command:{}", c.label),
|
||||
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
|
||||
}
|
||||
}
|
||||
|
||||
fn positions(result: &MatchResult) -> Vec<usize> {
|
||||
result
|
||||
.ranges
|
||||
.iter()
|
||||
.flat_map(|&(start, end)| start..end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn expect_results(
|
||||
query: &str,
|
||||
candidates: &[MatchCandidate],
|
||||
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
|
||||
) {
|
||||
let results = match_and_rank(query, candidates);
|
||||
|
||||
let actual: Vec<(String, i32)> = results
|
||||
.iter()
|
||||
.map(|r| (label(r.candidate), r.score))
|
||||
.collect();
|
||||
let expected_labels: Vec<(String, i32)> = expected
|
||||
.iter()
|
||||
.map(|(l, s, _, _)| (l.to_string(), *s))
|
||||
.collect();
|
||||
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
|
||||
|
||||
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
|
||||
let expected_fields: Vec<Option<MatchFieldName>> =
|
||||
expected.iter().map(|(_, _, f, _)| *f).collect();
|
||||
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
|
||||
|
||||
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
|
||||
let expected_positions: Vec<Vec<usize>> =
|
||||
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
|
||||
assert_eq!(
|
||||
actual_positions, expected_positions,
|
||||
"positions for query {query:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_empty_query_keeps_input_order() {
|
||||
expect_results(
|
||||
"",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
("session:kill", 0, None, &[]),
|
||||
("session:killer-app", 0, None, &[]),
|
||||
("command:Kill selected session", 0, None, &[]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
|
||||
expect_results(
|
||||
"kill",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
session(
|
||||
"killer-app",
|
||||
Some("~/dev/killer-app"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:kill",
|
||||
178,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"session:killer-app",
|
||||
146,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_path_segment_boundaries_beat_mid_segment() {
|
||||
expect_results(
|
||||
"foo",
|
||||
&[
|
||||
directory("~/dev/foobar"),
|
||||
directory("~/src/myfoo"),
|
||||
directory("~/foo"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"directory:~/foo",
|
||||
78,
|
||||
Some(MatchFieldName::Path),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"directory:~/dev/foobar",
|
||||
74,
|
||||
Some(MatchFieldName::Path),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"directory:~/src/myfoo",
|
||||
56,
|
||||
Some(MatchFieldName::Path),
|
||||
&[8, 9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_no_match_returns_empty() {
|
||||
expect_results(
|
||||
"zzz",
|
||||
&[
|
||||
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||
command("Kill selected session"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f5_highest_scoring_embedding_beats_greedy() {
|
||||
expect_results(
|
||||
"mtu",
|
||||
&[
|
||||
session("maestro-tui", None, None, None),
|
||||
session("maestro-daemon", None, None, None),
|
||||
session("mtui-scratch", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:mtui-scratch",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:maestro-tui",
|
||||
91,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 8, 9],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f6_name_beats_weighted_directory() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("api", Some("~/dev/web"), Some("bash"), None),
|
||||
session("worker", Some("~/dev/api"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
|
||||
(
|
||||
"session:worker",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f7_case_insensitive_length_breaks_tie() {
|
||||
expect_results(
|
||||
"api",
|
||||
&[
|
||||
session("API-Gateway", None, None, None),
|
||||
session("apiary", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:apiary",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
(
|
||||
"session:API-Gateway",
|
||||
122,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 1, 2],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f8_exact_tool_beats_command_field() {
|
||||
expect_results(
|
||||
"claude",
|
||||
&[
|
||||
session(
|
||||
"nameless",
|
||||
Some("~/dev/x"),
|
||||
Some("claude --resume"),
|
||||
Some("claude"),
|
||||
),
|
||||
session("clay", Some("~/dev/y"), Some("bash"), None),
|
||||
],
|
||||
&[(
|
||||
"session:nameless",
|
||||
186,
|
||||
Some(MatchFieldName::Tool),
|
||||
&[0, 1, 2, 3, 4, 5],
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f9_separator_earns_word_boundary_bonus() {
|
||||
expect_results(
|
||||
"ws",
|
||||
&[
|
||||
session("websearch", None, None, None),
|
||||
session("web-search", None, None, None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:web-search",
|
||||
71,
|
||||
Some(MatchFieldName::Name),
|
||||
&[0, 4],
|
||||
),
|
||||
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f10_command_labels_rank_by_boundary_and_gap() {
|
||||
expect_results(
|
||||
"se",
|
||||
&[
|
||||
command("Kill selected session"),
|
||||
command("Create a session"),
|
||||
command("Show help"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"command:Show help",
|
||||
53,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 6],
|
||||
),
|
||||
(
|
||||
"command:Kill selected session",
|
||||
51,
|
||||
Some(MatchFieldName::Label),
|
||||
&[5, 6],
|
||||
),
|
||||
(
|
||||
"command:Create a session",
|
||||
47,
|
||||
Some(MatchFieldName::Label),
|
||||
&[9, 10],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f11_directory_matches_separate_on_initial_gap() {
|
||||
expect_results(
|
||||
"dev",
|
||||
&[
|
||||
session("api", Some("~/dev/api"), Some("bash"), None),
|
||||
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
|
||||
session("scratch", Some("~/srv/devops"), Some("bash"), None),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"session:api",
|
||||
54,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[2, 3, 4],
|
||||
),
|
||||
(
|
||||
"session:scratch",
|
||||
50,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[6, 7, 8],
|
||||
),
|
||||
(
|
||||
"session:notes",
|
||||
44,
|
||||
Some(MatchFieldName::Directory),
|
||||
&[12, 13, 14],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f12_exact_beats_prefix() {
|
||||
expect_results(
|
||||
"quit",
|
||||
&[command("Quit Maestro"), command("Quit")],
|
||||
&[
|
||||
(
|
||||
"command:Quit",
|
||||
178,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
(
|
||||
"command:Quit Maestro",
|
||||
146,
|
||||
Some(MatchFieldName::Label),
|
||||
&[0, 1, 2, 3],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -813,6 +813,21 @@ impl Palette {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background for the card whose session the pane is showing
|
||||
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
|
||||
/// selection surface, so the sidebar reads as one ladder: unfilled, half
|
||||
/// filled for the session in the pane, fully filled under the cursor.
|
||||
///
|
||||
/// Blended rather than added as a third `Surface` — a new surface means
|
||||
/// eight more colours hand-picked seventeen times — which is also why it
|
||||
/// borrows the *selection* surface's foregrounds: those are the ones
|
||||
/// already picked for a filled row. A preset whose colours the terminal
|
||||
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
|
||||
/// says "in the pane" with the filled glyph alone.
|
||||
pub fn pane_row_bg(&self) -> Color {
|
||||
mix(self.card.bg, self.selection.bg, 0.55)
|
||||
}
|
||||
|
||||
/// Border style for a panel, by whether it holds focus.
|
||||
pub fn border_style(&self, focused: bool) -> Style {
|
||||
if focused {
|
||||
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
|
||||
*active_slot().read().expect("active palette lock poisoned")
|
||||
}
|
||||
|
||||
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
|
||||
/// there is nothing to blend and `from` comes back unchanged.
|
||||
fn mix(from: Color, to: Color, amount: f64) -> Color {
|
||||
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
|
||||
return from;
|
||||
};
|
||||
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
|
||||
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
|
||||
}
|
||||
|
||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||
/// luminance — they are whatever the terminal theme says — so they return
|
||||
/// `None` and cannot be contrast-checked.
|
||||
@@ -937,6 +962,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane row is the middle rung: a visible fill against the card, still
|
||||
/// a different surface from the cursor's, and carrying the selection
|
||||
/// surface's foregrounds, which it borrows rather than shipping its own.
|
||||
#[test]
|
||||
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
|
||||
for (preset, p) in rgb_presets() {
|
||||
let bg = p.pane_row_bg();
|
||||
let from_card = contrast_ratio(p.card.bg, bg)
|
||||
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
|
||||
assert!(
|
||||
from_card >= 1.15,
|
||||
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
|
||||
);
|
||||
let from_selection =
|
||||
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
|
||||
assert!(
|
||||
from_selection >= 1.15,
|
||||
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
|
||||
);
|
||||
for (role, color) in p.selection.roles() {
|
||||
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
|
||||
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset the terminal themes has nothing to blend from.
|
||||
#[test]
|
||||
fn ansi_keeps_the_card_background_for_the_pane_row() {
|
||||
let p = Palette::ANSI;
|
||||
assert_eq!(p.pane_row_bg(), p.card.bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_supplies_every_role() {
|
||||
for (name, p) in Palette::presets() {
|
||||
|
||||
@@ -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);
|
||||
@@ -415,6 +426,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let state = AppState::new();
|
||||
@@ -427,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);
|
||||
@@ -511,6 +524,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
@@ -522,6 +536,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
|
||||
@@ -561,6 +576,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let draw_map = |focus| {
|
||||
@@ -575,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);
|
||||
@@ -601,6 +618,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let render = |focus| {
|
||||
@@ -615,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);
|
||||
|
||||
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
|
||||
/// both card rows, replacing the old inline dot glyph on the meta line.
|
||||
const RAIL: &str = "▌";
|
||||
|
||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
|
||||
/// on its own row: a highlight that lands on the session already in the pane
|
||||
/// still has to be visible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CardFill {
|
||||
/// An ordinary card, on the card surface.
|
||||
None,
|
||||
/// The session the pane is showing.
|
||||
Pane,
|
||||
/// Under the cursor, with the sidebar holding the keyboard.
|
||||
Cursor,
|
||||
}
|
||||
|
||||
impl CardFill {
|
||||
/// Both filled tiers carry the selection surface's foregrounds — those are
|
||||
/// the colours picked for a filled row, and a colour chosen for the card
|
||||
/// background is not legible on one.
|
||||
fn filled(self) -> bool {
|
||||
self != CardFill::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Cards take the cursor's fill only while the sidebar owns focus. An
|
||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||
/// keyboard via a filled selection background.
|
||||
fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||
fn card_base(fill: CardFill, focused: bool) -> Style {
|
||||
let palette = palette::active();
|
||||
let mut style = palette.surface(highlighted && focused).text_style();
|
||||
if focused && !highlighted {
|
||||
style = style.bg(palette.sidebar_focus_bg);
|
||||
let mut style = palette.surface(fill.filled()).text_style();
|
||||
match fill {
|
||||
CardFill::Cursor => {}
|
||||
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
|
||||
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
|
||||
CardFill::None => {}
|
||||
}
|
||||
style
|
||||
}
|
||||
@@ -55,6 +80,9 @@ pub struct Sidebar {
|
||||
pub sessions: Vec<Session>,
|
||||
pub highlight: Option<SidebarItem>,
|
||||
pub attached: HashSet<Uuid>,
|
||||
/// The session the active tab's pane is showing. It wears a fill of its
|
||||
/// own, so "in the pane" is not carried by the ● glyph alone.
|
||||
pub pane_session: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -113,8 +141,7 @@ impl Sidebar {
|
||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||
if !group.collapsed {
|
||||
for s in &group.sessions {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let base = card_base(self.card_fill(s, focused), focused);
|
||||
lines.push(Line::from(self.name_row(s, width, focused)));
|
||||
let meta = self.meta_row(s, width, focused);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
@@ -226,6 +253,18 @@ impl Sidebar {
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
/// Which fill this session's card wears. The cursor wins on its own row,
|
||||
/// so a highlight parked on the session already in the pane stays visible.
|
||||
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
|
||||
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
|
||||
CardFill::Cursor
|
||||
} else if self.pane_session == Some(s.id) {
|
||||
CardFill::Pane
|
||||
} else {
|
||||
CardFill::None
|
||||
}
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
@@ -233,12 +272,13 @@ impl Sidebar {
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
|
||||
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
@@ -257,7 +297,7 @@ impl Sidebar {
|
||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||
base.patch(
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
)
|
||||
@@ -283,11 +323,11 @@ impl Sidebar {
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let rail_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
let edge = highlighted && !focused;
|
||||
@@ -308,7 +348,7 @@ impl Sidebar {
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(highlighted && focused);
|
||||
let surface = palette.surface(fill.filled());
|
||||
for (kind, text) in b.stats() {
|
||||
let style = match kind {
|
||||
StatKind::Added => muted.fg(surface.active),
|
||||
@@ -352,10 +392,8 @@ impl Sidebar {
|
||||
/// state word is never spelled out — this colour carries it. The highlighted
|
||||
/// card takes its state colours from the selection surface: a colour picked
|
||||
/// for the dark card background is not legible on the selection background.
|
||||
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.activity(state)
|
||||
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
|
||||
palette::active().surface(fill.filled()).activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -521,6 +559,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||
assert!(
|
||||
@@ -545,6 +584,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
})
|
||||
.groups(&HashSet::new());
|
||||
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||
@@ -556,6 +596,7 @@ mod tests {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||
assert!(sidebar.rendered_items().is_empty());
|
||||
@@ -567,6 +608,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let key = PathBuf::from("/tmp");
|
||||
let mut collapsed = HashSet::new();
|
||||
@@ -591,6 +633,7 @@ mod tests {
|
||||
sessions: vec![session],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
||||
let collapsed = HashSet::from([key]);
|
||||
@@ -621,6 +664,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sidebar, 40, 8));
|
||||
assert!(all[1].contains("/tmp"));
|
||||
@@ -636,6 +680,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 6));
|
||||
let line = row_containing(&all, "claude");
|
||||
@@ -646,6 +691,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether any cell in the buffer is painted on `bg`.
|
||||
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
|
||||
(0..buf.area.height)
|
||||
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
|
||||
/// not the ● glyph alone carrying "this is the one on screen".
|
||||
#[test]
|
||||
fn the_session_in_the_pane_wears_the_pane_fill() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
|
||||
|
||||
let none = Sidebar {
|
||||
pane_session: None,
|
||||
..sb
|
||||
};
|
||||
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
|
||||
}
|
||||
|
||||
/// The cursor's fill wins on its own row: a highlight that lands on the
|
||||
/// session already in the pane has to stay visible.
|
||||
#[test]
|
||||
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(sid)),
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 6, true);
|
||||
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
|
||||
assert!(
|
||||
!any_cell_on(&buf, p.pane_row_bg()),
|
||||
"the pane fill is showing through the cursor's own row"
|
||||
);
|
||||
}
|
||||
|
||||
/// A card the pane is showing is a filled row, so it takes the selection
|
||||
/// surface's foregrounds — a colour picked for the card background is not
|
||||
/// legible on a fill.
|
||||
#[test]
|
||||
fn the_pane_row_carries_selection_foregrounds() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let p = palette::active();
|
||||
let mut sess = session_named(1, "claude");
|
||||
sess.activity = ActivityState::AwaitingInput;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![sess],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
pane_session: Some(sid),
|
||||
};
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let on_pane_row = |fg: Color| {
|
||||
(0..buf.area.height).any(|y| {
|
||||
(0..buf.area.width).any(|x| {
|
||||
let st = buf[(x, y)].style();
|
||||
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
|
||||
"the rail must carry the state in the filled surface's colours"
|
||||
);
|
||||
assert!(
|
||||
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
|
||||
"a card surface colour is painted onto the pane fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_session_uses_cross_glyph() {
|
||||
let mut sess = session_named(1, "claude");
|
||||
@@ -654,6 +780,7 @@ mod tests {
|
||||
sessions: vec![sess],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let all = rows(&buf);
|
||||
@@ -682,6 +809,7 @@ mod tests {
|
||||
sessions: vec![blocked, clear, unprobed],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 44, 10));
|
||||
|
||||
@@ -728,6 +856,7 @@ mod tests {
|
||||
sessions: vec![generated, settled],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
@@ -760,6 +889,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
@@ -786,6 +916,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
@@ -817,6 +948,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -856,6 +988,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let found = (0..buf.area.height)
|
||||
@@ -884,6 +1017,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let on_selection = |fg: Color| {
|
||||
@@ -923,6 +1057,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -942,6 +1077,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 60, 6));
|
||||
let meta = content(&all[3]);
|
||||
@@ -970,6 +1106,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -999,6 +1136,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1026,6 +1164,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1054,6 +1193,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 42, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
@@ -1087,6 +1227,7 @@ mod tests {
|
||||
sessions: vec![legacy],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 40, 8));
|
||||
assert!(
|
||||
@@ -1130,6 +1271,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||
assert!(
|
||||
@@ -1145,6 +1287,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
@@ -1166,6 +1309,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 9, 6));
|
||||
assert!(
|
||||
@@ -1192,6 +1336,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
@@ -1216,6 +1361,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
@@ -1237,6 +1383,7 @@ mod tests {
|
||||
sessions: vec![highlighted],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
for y in 0..buf.area.height {
|
||||
@@ -1262,6 +1409,7 @@ mod tests {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let all = rows(&buf);
|
||||
@@ -1297,6 +1445,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||
let y = (0..buf.area.height)
|
||||
@@ -1316,6 +1465,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
},
|
||||
28,
|
||||
8,
|
||||
@@ -1336,6 +1486,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||
@@ -1354,6 +1505,7 @@ mod tests {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = content(row_containing(&all, "claude"));
|
||||
@@ -1371,6 +1523,7 @@ mod tests {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "bash");
|
||||
@@ -1390,6 +1543,7 @@ mod tests {
|
||||
sessions: vec![s],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "an-unreasonably");
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,33 @@ The throwaway tab never counts as an open session: the tab bar does not appear
|
||||
because of it, the mobile shell does not switch away from the session list for
|
||||
it, and the sidebar does not mark the session as open.
|
||||
|
||||
## How a preview reads
|
||||
|
||||
A preview is provisional, so it never wears the accent — the accent is what
|
||||
says "this is the one you picked". It has a hue of its own, and every surface
|
||||
involved in the preview carries that same hue, so the pane and the row it came
|
||||
from read as one thing rather than two unrelated highlights.
|
||||
|
||||
Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
|
||||
| Surface | While previewing |
|
||||
|---|---|
|
||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
|
||||
belong to the pane: the canvas ring and the throwaway tab.
|
||||
|
||||
The hue is a palette token, not a fixed colour: a palette whose accent already
|
||||
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
|
||||
|
||||
The TUI marks a preview in text only — the title bar reads `<name> · preview`
|
||||
— and has no preview colour role yet.
|
||||
|
||||
## What ends a preview
|
||||
|
||||
- The highlight moves to another session — that session is previewed instead.
|
||||
|
||||
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
|
||||
selection background. Contrast is a property of the palette (values are RGB)
|
||||
and is asserted in the palette's tests, not left to the terminal theme.
|
||||
|
||||
## Which row is filled
|
||||
|
||||
Both clients say every one of these with a **background fill**, never an edge.
|
||||
A row is usually several of them at once, so they form a ladder: the tier
|
||||
furthest down wins the paint.
|
||||
|
||||
| Tier | Web | TUI |
|
||||
|---|---|---|
|
||||
| Alive | 6% accent wash | — (the rail carries it) |
|
||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
||||
|
||||
The web ladder is written as one ordered run of equal-specificity rules in
|
||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||
*is* the precedence. Add a tier where it belongs in that run, never by piling
|
||||
on another class to out-weigh the rule above it.
|
||||
|
||||
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
|
||||
since those are the colours picked for a filled row. `pane_row_bg()` is
|
||||
blended from the two surfaces rather than shipped as a third one — a third
|
||||
surface would mean eight more colours hand-picked seventeen times — and the
|
||||
palette tests assert it stays a visible step from both. The cursor's fill wins
|
||||
on its own row, so a highlight parked on the session already in the pane is
|
||||
still visible.
|
||||
|
||||
The one edge left in either list is the TUI's `▏` marker, which stands in for
|
||||
the cursor's fill while the pane holds the keyboard, and the web's ring on a
|
||||
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||
step up to.
|
||||
|
||||
The duration text inherits its state's colour; the exited and command
|
||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||
|
||||
@@ -388,6 +388,9 @@ export interface SidebarHandle {
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
||||
* with `null`. A preview is a view, not state, so this only paints. */
|
||||
setPreview(sessionId: string | null): void;
|
||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||
focusList(): void;
|
||||
/** Whether the session list currently holds DOM focus. */
|
||||
@@ -547,6 +550,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
// The previewed row is painted alongside the open/focused tiers so a
|
||||
// re-render can't drop it, but it is never part of `highlights`: a preview
|
||||
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
||||
let previewing: string | null = null;
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
@@ -555,6 +562,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
n.classList.toggle("is-previewing", previewing === id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -762,6 +770,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
setPreview(sessionId: string | null) {
|
||||
previewing = sessionId;
|
||||
applyHighlights();
|
||||
},
|
||||
focusList() {
|
||||
// Start from the session on screen — that is where the user's attention
|
||||
// already is — falling back to the standing highlight, then the top row.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeCommandBar();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", {
|
||||
last_focused_at: "2020-01-01T00:00:00Z",
|
||||
created_at: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"new-fresh",
|
||||
"old-fresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const deadRecent = session("dead", {
|
||||
status: "Dead",
|
||||
last_focused_at: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
const liveNever = session("never");
|
||||
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
|
||||
"live",
|
||||
"never",
|
||||
"dead",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a null last_focused_at as never focused", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
command: "claude --resume",
|
||||
current_cwd: "/home/me/dev/api",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me")).toEqual({
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every session in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const candidates = sessionBarCandidates([
|
||||
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
|
||||
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
|
||||
]);
|
||||
const rows = barRows("api", candidates);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].title).toBe("Sessions");
|
||||
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
|
||||
rows.map((r) => r.candidate.session.id),
|
||||
);
|
||||
// F6: the name beats the directory, and the directory match highlights
|
||||
// inside the directory.
|
||||
expect(groups[0].results[0].candidate.session.id).toBe("api");
|
||||
expect(groups[0].results[1].field).toBe("directory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
||||
}
|
||||
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("opening twice leaves one overlay", () => {
|
||||
open();
|
||||
open();
|
||||
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
|
||||
expect(marks).toEqual(["wor"]);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("notes");
|
||||
const row = document.querySelector(".cmdbar-row") as HTMLElement;
|
||||
expect(row.dataset.sessionId).toBe("notes");
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the shared open path on Enter", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect(seen).toEqual(["worker"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
open();
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
|
||||
open();
|
||||
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
open();
|
||||
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true }),
|
||||
);
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("button");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
open();
|
||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||
press("Escape");
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
||||
// terminal-focus binding land in #150-#152.
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
/** A matcher candidate that remembers which session it came from, so a result
|
||||
* can be acted on without a second lookup. The extra property is invisible to
|
||||
* the matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: BarResult[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: 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. */
|
||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
const groups: BarGroup[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
|
||||
* matcher as `[start, end)` character pairs over the field that won. */
|
||||
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) frag.append(text.slice(cursor, start));
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "cmdbar-hit";
|
||||
mark.textContent = text.slice(start, end);
|
||||
frag.append(mark);
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) frag.append(text.slice(cursor));
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function metaText(result: BarResult): string | undefined {
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmdbar-row";
|
||||
row.setAttribute("role", "option");
|
||||
row.id = `cmdbar-row-${index}`;
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.sessionId = result.candidate.session.id;
|
||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "cmdbar-row-name";
|
||||
const nameText = result.candidate.fields.name;
|
||||
name.append(
|
||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
||||
);
|
||||
row.appendChild(name);
|
||||
|
||||
const meta = metaText(result);
|
||||
if (meta) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "cmdbar-row-meta";
|
||||
const highlighted = result.field !== undefined && result.field !== "name";
|
||||
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
|
||||
row.appendChild(el);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---- the overlay ----
|
||||
|
||||
export interface CommandBarOptions {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** Open the highlighted session. Defaults to the shared open path — the
|
||||
* sidebar's click event — which is what the contract requires; tests pass
|
||||
* their own. */
|
||||
onOpenSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
el: HTMLElement;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
let current: OpenBar | null = null;
|
||||
|
||||
export function isCommandBarOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
export function closeCommandBar(): void {
|
||||
current?.dispose();
|
||||
}
|
||||
|
||||
/** Mount the bar. Opening while one is up replaces it, so a stray second call
|
||||
* can never stack two overlays. */
|
||||
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
closeCommandBar();
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const openSession =
|
||||
opts.onOpenSession ??
|
||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||
|
||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "cmdbar-overlay";
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("aria-label", "Command bar");
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "cmdbar-panel";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "cmdbar-input";
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", "cmdbar-results");
|
||||
input.setAttribute("aria-label", "Search sessions");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.spellcheck = false;
|
||||
input.placeholder = "Go to a session…";
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
results.id = "cmdbar-results";
|
||||
results.setAttribute("role", "listbox");
|
||||
|
||||
panel.append(input, results);
|
||||
el.appendChild(panel);
|
||||
document.body.appendChild(el);
|
||||
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
function paintHighlight(): void {
|
||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||
const active = Number(row.dataset.index) === highlighted;
|
||||
row.classList.toggle("is-active", active);
|
||||
row.setAttribute("aria-selected", active ? "true" : "false");
|
||||
if (active) {
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
input.setAttribute("aria-activedescendant", row.id);
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
rows = barRows(input.value, candidates);
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
highlighted = 0;
|
||||
results.replaceChildren();
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "cmdbar-empty";
|
||||
empty.textContent = "No matches";
|
||||
results.appendChild(empty);
|
||||
paintHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
for (const result of group.results) {
|
||||
const at = index;
|
||||
const row = renderRow(result, at);
|
||||
row.addEventListener("click", () => activate(at));
|
||||
results.appendChild(row);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
function move(delta: number): void {
|
||||
if (rows.length === 0) return;
|
||||
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const sessionId = result.candidate.session.id;
|
||||
close();
|
||||
openSession(sessionId);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (current?.el !== el) return;
|
||||
current = null;
|
||||
el.remove();
|
||||
// Restoring focus is what makes the bar a view over the app rather than a
|
||||
// place in it; a detached node cannot take it.
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
}
|
||||
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
input.addEventListener("input", render);
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
el.addEventListener("mousedown", (ev) => {
|
||||
if (!panel.contains(ev.target as Node)) close();
|
||||
});
|
||||
|
||||
current = { el, dispose: close };
|
||||
render();
|
||||
input.focus();
|
||||
return el;
|
||||
}
|
||||
@@ -49,6 +49,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: ["N"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "bar.open",
|
||||
label: "Open the command bar",
|
||||
group: "Sessions",
|
||||
keys: ["ctrl-k"],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
|
||||
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
|
||||
"--muted",
|
||||
"--faint",
|
||||
"--accent",
|
||||
"--preview",
|
||||
"--danger",
|
||||
"--warn",
|
||||
];
|
||||
|
||||
+43
-7
@@ -7,7 +7,8 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
@@ -80,6 +81,15 @@ async function bootApp(): Promise<void> {
|
||||
|
||||
const store = new CatalogStore();
|
||||
|
||||
// The daemon's home dir, for the command bar's `~/…` directory abbreviation.
|
||||
// Absolute paths are a fine fallback until it lands.
|
||||
let homeDir: string | undefined;
|
||||
void getDaemonInfo()
|
||||
.then((info) => (homeDir = info.home_dir))
|
||||
.catch(() => {
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||
// dot, and blocks the UI behind the restart overlay while the daemon is away
|
||||
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
||||
@@ -399,12 +409,38 @@ const focusKeys = new CommandSet(
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
spawnKeys.handle(e);
|
||||
});
|
||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||
// like any other, so the `?` overlay lists it without a second list.
|
||||
const barKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"bar.open": () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||
if (isCommandBarOpen()) return;
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
// Opening the bar over a focused terminal is #152; until then the pane's
|
||||
// hidden textarea keeps its Ctrl+K, like any other typing target.
|
||||
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||
spawnKeys.handle(e);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||
|
||||
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setPreview() {},
|
||||
focusList() {},
|
||||
hasListFocus() {
|
||||
return false;
|
||||
|
||||
@@ -221,6 +221,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const tab = activeTab(layout);
|
||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||
// The previewed pane's titlebar wears the preview colour instead of the
|
||||
// accent tint a focused pane gets (docs/PREVIEW.md).
|
||||
pane.classList.toggle(
|
||||
"is-preview",
|
||||
preview !== null && pane.dataset.sessionId === preview.sessionId,
|
||||
);
|
||||
}
|
||||
// A preview is not a focused pane: reporting it would clear the session's
|
||||
// `AwaitingInput` glow just because the operator scrolled past it.
|
||||
@@ -229,6 +235,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
open: realOpenSessions(),
|
||||
focused: focusedLeafSession(),
|
||||
});
|
||||
// The row the preview came from wears the same colour, so the sidebar and
|
||||
// the pane read as one thing rather than two unrelated highlights.
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
function focusLeaf(leafId: string): void {
|
||||
@@ -549,6 +558,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
@@ -588,6 +598,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
const pane = buildPane(focused);
|
||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
|
||||
wrap.appendChild(pane.el);
|
||||
canvas.appendChild(wrap);
|
||||
|
||||
@@ -598,6 +609,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
function closeSession(sessionId: string): void {
|
||||
|
||||
@@ -45,11 +45,17 @@ function fakePool(): Spy {
|
||||
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
|
||||
}
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
const previewRow: { id: string | null } = { id: null };
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
focusList() {},
|
||||
hasListFocus() {
|
||||
return false;
|
||||
@@ -88,6 +94,7 @@ describe("sidebar preview", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,6 +117,23 @@ describe("sidebar preview", () => {
|
||||
expect(serializeLayout(layout)).toEqual(before);
|
||||
});
|
||||
|
||||
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
|
||||
const layout = twoTabs();
|
||||
const spy = fakePool();
|
||||
const mv = makeMv(layout, spy.pool);
|
||||
|
||||
mv.previewSession("s1");
|
||||
expect(previewRow.id).toBe("s1");
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
|
||||
expect(
|
||||
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
|
||||
).toBe("s1");
|
||||
|
||||
mv.previewSession(null);
|
||||
expect(previewRow.id).toBeNull();
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
+205
-32
@@ -87,6 +87,30 @@
|
||||
--accent-tint: oklch(0.92 0.005 256 / 0.1);
|
||||
--accent-ring: oklch(0.92 0.005 256 / 0.5);
|
||||
|
||||
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
|
||||
the accent, because the accent is what says "this is the one you picked".
|
||||
Defined once, off a single hue variable, so a palette whose accent already
|
||||
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
|
||||
keeps the light pair here instead of in all eighteen theme blocks. */
|
||||
--preview-h: 305;
|
||||
--preview: light-dark(
|
||||
oklch(0.55 0.16 var(--preview-h)),
|
||||
oklch(0.78 0.14 var(--preview-h))
|
||||
);
|
||||
--preview-ink: light-dark(
|
||||
oklch(0.99 0.005 var(--preview-h)),
|
||||
oklch(0.19 0.045 var(--preview-h))
|
||||
);
|
||||
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
|
||||
the same colour whatever tier the row was already painted. `--accent-fill`
|
||||
is the same recipe on the accent, so the two tiers differ by hue alone. */
|
||||
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
|
||||
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
|
||||
--preview-ring: light-dark(
|
||||
oklch(0.55 0.16 var(--preview-h) / 0.5),
|
||||
oklch(0.78 0.14 var(--preview-h) / 0.6)
|
||||
);
|
||||
|
||||
--live: oklch(0.84 0.17 152);
|
||||
--live-glow: oklch(0.84 0.17 152 / 0.55);
|
||||
|
||||
@@ -256,6 +280,9 @@
|
||||
--accent-ink: oklch(0.18 0.04 278);
|
||||
--accent-tint: oklch(0.75 0.125 275 / 0.14);
|
||||
--accent-ring: oklch(0.75 0.125 275 / 0.55);
|
||||
/* This accent is already violet, so preview moves to cyan to stay legible
|
||||
as "not the accent". Set once here: the light blocks below don't touch it. */
|
||||
--preview-h: 195;
|
||||
--term-bg: oklch(0.155 0.018 278);
|
||||
}
|
||||
:root[data-palette="periwinkle"][data-theme="light"],
|
||||
@@ -437,6 +464,8 @@
|
||||
--accent-ink: oklch(0.22 0.05 310);
|
||||
--accent-tint: oklch(0.78 0.13 310 / 0.14);
|
||||
--accent-ring: oklch(0.78 0.13 310 / 0.55);
|
||||
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
|
||||
--preview-h: 195;
|
||||
--term-bg: oklch(0.155 0.014 305);
|
||||
}
|
||||
:root[data-palette="orchid"][data-theme="light"],
|
||||
@@ -1114,35 +1143,42 @@ textarea {
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||
a visible delay between the pointer landing on a row and the row admitting
|
||||
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||
follows a keyboard walk, not the pointer. */
|
||||
/* No transition on `background`: it is what hover, the keyboard cursor and
|
||||
the preview all paint, and easing it puts a visible delay between the input
|
||||
landing on a row and the row admitting it. `.is-active`'s ring still eases
|
||||
— being open in a pane is a state, not a response to the pointer. */
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||
.tree-leaf[data-status="running"] {
|
||||
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
|
||||
once, so every qualifier below sits in `:where()`: each rule is worth the
|
||||
same specificity and the ladder is decided purely by the order they appear
|
||||
in, latest wins. Add a tier where it belongs in that order, not by piling on
|
||||
another class to out-weigh the rule above it. Each tier paints a background
|
||||
and nothing else — the list says all of this with fill, never an edge. */
|
||||
|
||||
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
|
||||
.tree-leaf:where([data-status="running"]) {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.tree-leaf:hover {
|
||||
background: var(--surface-2);
|
||||
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
|
||||
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
|
||||
which belongs to the row actually open. */
|
||||
.tree-leaf:where(:hover, .is-highlighted) {
|
||||
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf[data-status="running"]:hover {
|
||||
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
|
||||
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
|
||||
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||
}
|
||||
|
||||
.tree-leaf:focus-visible {
|
||||
.tree-leaf:where(:focus-visible) {
|
||||
outline: none;
|
||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
||||
is a cursor, not a statement about what is mounted, so it reads as a ring
|
||||
rather than a fill and the two can sit on the same row. */
|
||||
.tree-leaf.is-highlighted,
|
||||
/* A folder row previews nothing, so its cursor stays a ring. */
|
||||
.tree-folder-header.is-highlighted {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
||||
}
|
||||
@@ -1151,17 +1187,26 @@ textarea {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The session currently mounted in the content pane. */
|
||||
.tree-leaf.is-active,
|
||||
.tree-leaf.is-active[data-status="running"] {
|
||||
background: var(--accent-tint);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||
/* 3. Open in some pane, but not the focused one (issue #33). */
|
||||
.tree-leaf:where(.is-open) {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* 4. The session currently mounted in the content pane. */
|
||||
.tree-leaf:where(.is-active) {
|
||||
background: var(--accent-fill);
|
||||
}
|
||||
|
||||
.tree-leaf.is-active .leaf-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||
session that is already open still reads as provisional. */
|
||||
.tree-leaf:where(.is-previewing) {
|
||||
background: var(--preview-tint);
|
||||
}
|
||||
|
||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||
never the running tint's absence alone doing the work. */
|
||||
.tree-leaf[data-status="dead"] {
|
||||
@@ -1378,6 +1423,128 @@ input.leaf-rename:focus {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */
|
||||
/* Same family of surface as the spawn modal and the help overlay: the overlay
|
||||
tokens below are theirs, not a third set. */
|
||||
.cmdbar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: grid;
|
||||
grid-template-rows: 12vh auto 1fr;
|
||||
justify-items: center;
|
||||
padding: var(--sp-4);
|
||||
background: oklch(0 0 0 / 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: min(60vh, 520px);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.cmdbar-input {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-15);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.cmdbar-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cmdbar-results {
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.cmdbar-group-title {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: var(--fs-11);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.cmdbar-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-1) var(--sp-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmdbar-row.is-active {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.cmdbar-row-name {
|
||||
font-size: var(--fs-13);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* A dead session still lists — it just is not where you just were. */
|
||||
.cmdbar-row.is-dead .cmdbar-row-name {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cmdbar-row-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmdbar-hit {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cmdbar-empty {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-13);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
||||
@media (max-width: 600px) {
|
||||
.cmdbar-overlay {
|
||||
grid-template-rows: 1fr auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
@@ -2885,10 +3052,14 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
|
||||
operator only highlighted, so it reads as provisional — a dashed tab and a
|
||||
dashed inset ring on the canvas — and reverts when the highlight moves. */
|
||||
operator only highlighted, so it reads as provisional — the preview hue on
|
||||
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
|
||||
reverts when the highlight moves. The hue is deliberately not the accent:
|
||||
the accent means "this is what you picked", which a preview is not. */
|
||||
.mv-tab.is-preview {
|
||||
border-style: dashed;
|
||||
border-color: var(--preview-ring);
|
||||
color: var(--preview);
|
||||
font-style: italic;
|
||||
}
|
||||
.mv.mv-previewing .mv-canvas::before {
|
||||
@@ -2901,8 +3072,8 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
font: 500 var(--fs-11) / 1.5 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
color: var(--preview-ink);
|
||||
background: var(--preview);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -2911,7 +3082,7 @@ input.leaf-rename:focus {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border: 1px dashed var(--accent-ring);
|
||||
border: 1px dashed var(--preview-ring);
|
||||
border-radius: var(--r-1);
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
@@ -3029,6 +3200,15 @@ input.leaf-rename:focus {
|
||||
.mv-pane:not(.is-focused) .attach-header {
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
|
||||
only thing saying the pane is provisional — and so it is never mistaken for
|
||||
the accent tint of the pane the operator actually focused. It keeps full
|
||||
strength even though a preview never takes focus. */
|
||||
.mv-pane.is-preview .attach-header,
|
||||
.mv-pane.is-preview.is-focused .attach-header {
|
||||
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
|
||||
opacity: 1;
|
||||
}
|
||||
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
|
||||
.mv-pane-empty {
|
||||
flex: 1;
|
||||
@@ -3118,13 +3298,6 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
/* sidebar two-tier highlight (issue #33).
|
||||
.is-active = focused pane (loud, from existing rule); .is-open = open
|
||||
elsewhere (quiet). */
|
||||
.tree-leaf.is-open {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface Session {
|
||||
* create one. `base_repo` is the repository's main checkout, which is what
|
||||
* the session tree groups by (#99). */
|
||||
worktree?: SessionWorktree | null;
|
||||
/** Wall-clock of when this session last became the pane a client was showing
|
||||
* (#145) — stamped by both `POST /api/sessions/:id/focus` and opening
|
||||
* `/api/attach/:id`. Absent until the session has been focused once, which is
|
||||
* how the command bar's empty query sorts a never-focused session after every
|
||||
* session that has one (`docs/COMMAND-BAR.md`). */
|
||||
last_focused_at?: IsoTimestamp | null;
|
||||
}
|
||||
|
||||
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||
|
||||
Reference in New Issue
Block a user