Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b434dd1d |
@@ -251,7 +251,6 @@ mod tests {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,50 +285,33 @@ impl SessionManager {
|
|||||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||||
/// (NAP-229). Callers send the buffer before pumping the live receiver
|
/// (NAP-229). Callers send the buffer before pumping the live receiver
|
||||||
/// so freshly-attached clients see the last screenful immediately.
|
/// so freshly-attached clients see the last screenful immediately.
|
||||||
///
|
|
||||||
/// Opening an attach is focusing (#145): it stamps
|
|
||||||
/// [`Session::last_focused_at`], the same field `set_focused` stamps, so
|
|
||||||
/// a client that only ever attaches sorts identically to one that
|
|
||||||
/// reports focus explicitly.
|
|
||||||
pub async fn subscribe_pty_with_replay(
|
pub async fn subscribe_pty_with_replay(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let (result, updated) = {
|
let sessions = self.inner.sessions.lock().await;
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let rec = sessions.get(&id)?;
|
||||||
let rec = sessions.get_mut(&id)?;
|
// For a live session, tmux's own screen beats the byte tail: the tail
|
||||||
rec.session.last_focused_at = Some(Utc::now());
|
// starts mid-stream, so a full-screen program (an agent TUI) replayed
|
||||||
let updated = rec.session.clone();
|
// from it renders as garbage until something forces a repaint. A dead
|
||||||
// For a live session, tmux's own screen beats the byte tail: the
|
// session has no pane left to capture, so it keeps the byte tail.
|
||||||
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
let alive = rec.session.status == SessionStatus::Running;
|
||||||
// replayed from it renders as garbage until something forces a
|
// The capture shells out to tmux while the replay lock is held, which
|
||||||
// repaint. A dead session has no pane left to capture, so it
|
// parks the reader for the length of one tmux round-trip. That only
|
||||||
// keeps the byte tail.
|
// happens on attach, and the alternative is a seam in the stream.
|
||||||
let alive = rec.session.status == SessionStatus::Running;
|
Some(rec.bridge.subscribe_with_snapshot(|| {
|
||||||
// The capture shells out to tmux while the replay lock is held,
|
if !alive {
|
||||||
// which parks the reader for the length of one tmux round-trip.
|
return None;
|
||||||
// That only happens on attach, and the alternative is a seam in
|
}
|
||||||
// the stream.
|
match tmux.capture_pane(id) {
|
||||||
let result = rec.bridge.subscribe_with_snapshot(|| {
|
Ok(snapshot) => snapshot,
|
||||||
if !alive {
|
Err(e) => {
|
||||||
return None;
|
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
|
||||||
|
None
|
||||||
}
|
}
|
||||||
match tmux.capture_pane(id) {
|
}
|
||||||
Ok(snapshot) => snapshot,
|
}))
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(result, updated)
|
|
||||||
};
|
|
||||||
let _ = self
|
|
||||||
.inner
|
|
||||||
.events
|
|
||||||
.send(CatalogEvent::SessionUpdated { session: updated });
|
|
||||||
Some(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has(&self, id: Uuid) -> bool {
|
pub async fn has(&self, id: Uuid) -> bool {
|
||||||
@@ -465,7 +448,6 @@ impl SessionManager {
|
|||||||
// The watcher fills this in on its first blocked-work pass; a
|
// The watcher fills this in on its first blocked-work pass; a
|
||||||
// fresh tree has nothing in the way anyway.
|
// fresh tree has nothing in the way anyway.
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -670,7 +652,6 @@ impl SessionManager {
|
|||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
@@ -857,26 +838,11 @@ impl SessionManager {
|
|||||||
/// Record whether a client's pane is currently showing `id`. Read by the
|
/// Record whether a client's pane is currently showing `id`. Read by the
|
||||||
/// activity pass's next tick to decide whether an `AwaitingInput` glow
|
/// activity pass's next tick to decide whether an `AwaitingInput` glow
|
||||||
/// has been looked at long enough to clear (`apply_focus_suppression`).
|
/// has been looked at long enough to clear (`apply_focus_suppression`).
|
||||||
/// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing
|
/// An unknown session is [`SessionError::NotFound`].
|
||||||
/// it does not, since the command bar's ordering cares about the last
|
|
||||||
/// time the operator arrived, not when they left. An unknown session is
|
|
||||||
/// [`SessionError::NotFound`].
|
|
||||||
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
|
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
|
||||||
let updated = {
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
rec.focused_since = focused.then(std::time::Instant::now);
|
||||||
rec.focused_since = focused.then(std::time::Instant::now);
|
|
||||||
if focused {
|
|
||||||
rec.session.last_focused_at = Some(Utc::now());
|
|
||||||
}
|
|
||||||
focused.then(|| rec.session.clone())
|
|
||||||
};
|
|
||||||
if let Some(session) = updated {
|
|
||||||
let _ = self
|
|
||||||
.inner
|
|
||||||
.events
|
|
||||||
.send(CatalogEvent::SessionUpdated { session });
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,41 +383,6 @@ async fn patch_unknown_session_returns_404() {
|
|||||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #145: `POST /.../focus` with `focused: true` over HTTP stamps
|
|
||||||
/// `last_focused_at`, observable on the next `GET /api/sessions`.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn post_focus_stamps_last_focused_at() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let session = h
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: "/tmp".into(),
|
|
||||||
command: "sleep 60".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(session.last_focused_at, None);
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.post(url(h.addr, &format!("/api/sessions/{}/focus", session.id)))
|
|
||||||
.json(&serde_json::json!({ "focused": true }))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(sessions[0].last_focused_at.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_owned_session_kills_it() {
|
async fn delete_owned_session_kills_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -1091,109 +1091,3 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
|
|
||||||
fx.manager.kill(s.id, false).await.expect("kill");
|
fx.manager.kill(s.id, false).await.expect("kill");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been
|
|
||||||
/// focused carries no `last_focused_at` at all, which is what lets a client
|
|
||||||
/// sort it after every session that has one (#145).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_freshly_created_session_has_never_been_focused() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let s = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
|
|
||||||
assert_eq!(s.last_focused_at, None);
|
|
||||||
assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// #145: `POST /.../focus` reporting `focused: true` stamps
|
|
||||||
/// `last_focused_at` and announces it on the event stream; reporting
|
|
||||||
/// `focused: false` (losing focus) leaves the stamp alone, since the bar
|
|
||||||
/// orders by when the operator last arrived, not when they left.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn set_focused_true_stamps_last_focused_at() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let s = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
|
|
||||||
let mut events = fx.manager.events();
|
|
||||||
let before = chrono::Utc::now();
|
|
||||||
fx.manager.set_focused(s.id, true).await.expect("focus");
|
|
||||||
|
|
||||||
let updated = fx.manager.snapshot().await[0].clone();
|
|
||||||
let stamp = updated.last_focused_at.expect("stamped on focus");
|
|
||||||
assert!(stamp >= before);
|
|
||||||
|
|
||||||
let mut saw_update = false;
|
|
||||||
while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await {
|
|
||||||
if let Ok(CatalogEvent::SessionUpdated { session }) = event {
|
|
||||||
if session.id == s.id && session.last_focused_at == Some(stamp) {
|
|
||||||
saw_update = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(saw_update, "expected a SessionUpdated carrying the stamp");
|
|
||||||
|
|
||||||
fx.manager.set_focused(s.id, false).await.expect("unfocus");
|
|
||||||
let after_unfocus = fx.manager.snapshot().await[0].clone();
|
|
||||||
assert_eq!(
|
|
||||||
after_unfocus.last_focused_at,
|
|
||||||
Some(stamp),
|
|
||||||
"losing focus must not clear the last-focused stamp"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same
|
|
||||||
/// field `set_focused` does, so a client that only ever attaches sorts
|
|
||||||
/// identically to one that reports focus explicitly.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn attaching_stamps_last_focused_at() {
|
|
||||||
let fx = DaemonFixture::new();
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let s = fx
|
|
||||||
.manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
assert_eq!(s.last_focused_at, None);
|
|
||||||
|
|
||||||
let before = chrono::Utc::now();
|
|
||||||
let (_replay, _rx) = fx
|
|
||||||
.manager
|
|
||||||
.subscribe_pty_with_replay(s.id)
|
|
||||||
.await
|
|
||||||
.expect("known session");
|
|
||||||
|
|
||||||
let updated = fx.manager.snapshot().await[0].clone();
|
|
||||||
let stamp = updated.last_focused_at.expect("attach stamps focus");
|
|
||||||
assert!(stamp >= before);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ fn sample(id: Uuid) -> Session {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,41 +202,6 @@ pub fn command_program(command: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
|
||||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
|
||||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
|
||||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
|
||||||
("claude", "claude"),
|
|
||||||
("claude-code", "claude"),
|
|
||||||
("codex", "codex"),
|
|
||||||
("opencode", "opencode"),
|
|
||||||
("gemini", "gemini"),
|
|
||||||
("aider", "aider"),
|
|
||||||
("cursor", "cursor"),
|
|
||||||
("cursor-agent", "cursor"),
|
|
||||||
("pi", "pi"),
|
|
||||||
("bash", "shell"),
|
|
||||||
("zsh", "shell"),
|
|
||||||
("sh", "shell"),
|
|
||||||
("fish", "shell"),
|
|
||||||
("dash", "shell"),
|
|
||||||
("ksh", "shell"),
|
|
||||||
("nu", "shell"),
|
|
||||||
("xonsh", "shell"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
|
||||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
|
||||||
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
|
||||||
/// the same tools the web's `splitCommand().tool` does.
|
|
||||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
|
||||||
let program = command_program(command).to_lowercase();
|
|
||||||
BY_PROGRAM
|
|
||||||
.iter()
|
|
||||||
.find(|(name, _)| *name == program)
|
|
||||||
.map(|(_, tool)| *tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
let mut chars = word.chars();
|
let mut chars = word.chars();
|
||||||
@@ -283,7 +248,6 @@ mod tests {
|
|||||||
activity_since: Utc::now(),
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,22 +475,4 @@ mod tests {
|
|||||||
assert_eq!(command_program("sudo"), "sudo");
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
assert_eq!(command_program(""), "");
|
assert_eq!(command_program(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
|
||||||
/// tools the web's `splitCommand().tool` does.
|
|
||||||
#[test]
|
|
||||||
fn command_tool_names_the_mark_the_card_shows() {
|
|
||||||
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
|
||||||
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
|
||||||
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
|
||||||
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Narrow on purpose: an unrecognised program has no mark, so the
|
|
||||||
/// candidate has no `tool` field to match rather than a wrong one.
|
|
||||||
#[test]
|
|
||||||
fn command_tool_is_absent_for_an_unrecognised_program() {
|
|
||||||
assert_eq!(command_tool("hx src/main.rs"), None);
|
|
||||||
assert_eq!(command_tool(""), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,14 +108,6 @@ pub struct Session {
|
|||||||
/// from daemons that predate the field stay forward-compatible.
|
/// from daemons that predate the field stay forward-compatible.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub blocked_work: Option<InTheWay>,
|
pub blocked_work: Option<InTheWay>,
|
||||||
/// Wall-clock time this session last became the pane a client was
|
|
||||||
/// showing (#145) — stamped by both `POST /.../focus` and opening
|
|
||||||
/// `/api/attach/:id`, since either one is the operator moving there.
|
|
||||||
/// `None` until the session has been focused at least once, which is
|
|
||||||
/// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`)
|
|
||||||
/// sorts a never-focused session after every session that has one.
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_focused_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -855,7 +847,6 @@ mod tests {
|
|||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,28 +896,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn last_focused_at_defaults_to_none_on_legacy_payload() {
|
|
||||||
// Pre-#145 payload omits the field; `None` means never-focused.
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"id": Uuid::from_u128(1),
|
|
||||||
"cwd": "/tmp",
|
|
||||||
"command": "bash",
|
|
||||||
"status": "Running",
|
|
||||||
"created_at": "2026-05-24T12:00:00Z"
|
|
||||||
});
|
|
||||||
let s: Session = serde_json::from_value(json).unwrap();
|
|
||||||
assert_eq!(s.last_focused_at, None);
|
|
||||||
|
|
||||||
let mut focused = sample_session();
|
|
||||||
focused.last_focused_at = Some(
|
|
||||||
DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc),
|
|
||||||
);
|
|
||||||
assert_eq!(focused, round_trip(&focused));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||||
// A daemon predating #102 omits `activity_since`; it must deserialize
|
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ pub enum Action {
|
|||||||
RenameHighlighted,
|
RenameHighlighted,
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
|
||||||
/// over the sessions, ranked by the shared matcher.
|
|
||||||
OpenCommandBar,
|
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
ShowHelp,
|
ShowHelp,
|
||||||
Quit,
|
Quit,
|
||||||
@@ -97,7 +94,6 @@ impl Action {
|
|||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
Action::RenameHighlighted => "Rename selected session".into(),
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
Action::OpenCommandBar => "Go to a session".into(),
|
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
Action::Quit => "Quit Maestro".into(),
|
Action::Quit => "Quit Maestro".into(),
|
||||||
@@ -124,7 +120,6 @@ impl Action {
|
|||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"Quit" => return Ok(Action::Quit),
|
||||||
@@ -244,23 +239,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The command bar is an ordinary keymap entry, so it must round-trip
|
|
||||||
/// like every other action (#153).
|
|
||||||
#[test]
|
|
||||||
fn open_command_bar_round_trips() {
|
|
||||||
assert_eq!(
|
|
||||||
Action::parse("OpenCommandBar").unwrap(),
|
|
||||||
Action::OpenCommandBar
|
|
||||||
);
|
|
||||||
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
|
|
||||||
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
Action::OpenCommandBar,
|
|
||||||
Action::RenameHighlighted,
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::OpenSpawnModal,
|
Action::OpenSpawnModal,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
use crate::daemon_link::DaemonLink;
|
use crate::daemon_link::DaemonLink;
|
||||||
use crate::draft::{DraftInputResult, DraftPane};
|
use crate::draft::{DraftInputResult, DraftPane};
|
||||||
@@ -79,9 +78,6 @@ pub struct App {
|
|||||||
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
/// same reason the worktree entries are.
|
/// same reason the worktree entries are.
|
||||||
rename_input: TextInput,
|
rename_input: TextInput,
|
||||||
/// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153),
|
|
||||||
/// kept here for the same reason the rename buffer is.
|
|
||||||
command_bar: Option<CommandBar>,
|
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -214,7 +210,6 @@ impl App {
|
|||||||
link,
|
link,
|
||||||
http,
|
http,
|
||||||
rename_input: TextInput::default(),
|
rename_input: TextInput::default(),
|
||||||
command_bar: None,
|
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -435,32 +430,6 @@ impl App {
|
|||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// The bar owns every key while it is up: the keymap must not see
|
|
||||||
// `j`, `x` or `q` as commands mid-query.
|
|
||||||
Some(OverlayKind::CommandBar) => {
|
|
||||||
let outcome = match self.command_bar.as_mut() {
|
|
||||||
Some(bar) => bar.handle_key(key),
|
|
||||||
None => BarOutcome::Close,
|
|
||||||
};
|
|
||||||
match outcome {
|
|
||||||
BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()),
|
|
||||||
BarOutcome::Close => {
|
|
||||||
self.close_command_bar();
|
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
|
||||||
}
|
|
||||||
BarOutcome::Open(BarTarget::Session(sid)) => {
|
|
||||||
self.close_command_bar();
|
|
||||||
// The same open path the sidebar's own attach uses, so
|
|
||||||
// a bar attach can never leave the layout in a state a
|
|
||||||
// highlight-and-enter could not produce.
|
|
||||||
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
|
||||||
(
|
|
||||||
OverlayDecision::Consumed,
|
|
||||||
self.dispatch(Action::AttachHighlighted),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
@@ -506,7 +475,6 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::OpenCommandBar => self.open_command_bar(),
|
|
||||||
Action::OpenSpawnModal => {
|
Action::OpenSpawnModal => {
|
||||||
self.open_spawn_modal();
|
self.open_spawn_modal();
|
||||||
effects.push(Effect::LoadDraft);
|
effects.push(Effect::LoadDraft);
|
||||||
@@ -569,10 +537,6 @@ impl App {
|
|||||||
/// highlight already sitting on the attached session — clears at once.
|
/// highlight already sitting on the attached session — clears at once.
|
||||||
fn refresh_preview(&mut self) {
|
fn refresh_preview(&mut self) {
|
||||||
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
||||||
// The bar is a view over the app, not a place in it: while it is
|
|
||||||
// up the sidebar highlight behind it previews nothing
|
|
||||||
// (`docs/COMMAND-BAR.md`, "Focus round-trip").
|
|
||||||
_ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None,
|
|
||||||
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -624,25 +588,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the command bar up over whatever is behind it (#153). Opening
|
|
||||||
/// while a session is previewed discards the preview first, like any
|
|
||||||
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
|
|
||||||
/// so closing gives the keyboard back to whatever held it.
|
|
||||||
fn open_command_bar(&mut self) {
|
|
||||||
let snapshot = self.catalog.snapshot();
|
|
||||||
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
|
|
||||||
self.pending_preview = None;
|
|
||||||
self.clear_preview();
|
|
||||||
self.state.show_overlay(OverlayKind::CommandBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take the bar down, dropping its query. Every exit goes through here so
|
|
||||||
/// a stale buffer can never outlive the overlay.
|
|
||||||
fn close_command_bar(&mut self) {
|
|
||||||
self.command_bar = None;
|
|
||||||
self.state.dismiss_overlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_preview(&mut self) {
|
fn clear_preview(&mut self) {
|
||||||
if self.state.preview.take().is_some() {
|
if self.state.preview.take().is_some() {
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
@@ -1277,7 +1222,6 @@ impl App {
|
|||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
rename_input: &self.rename_input,
|
rename_input: &self.rename_input,
|
||||||
command_bar: self.command_bar.as_ref(),
|
|
||||||
link: &link_status,
|
link: &link_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1726,7 +1670,6 @@ mod tests {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -1907,7 +1850,6 @@ mod tests {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -1934,7 +1876,6 @@ mod tests {
|
|||||||
activity_since: Utc::now(),
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2772,181 +2713,6 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- command bar (#153) -------------------------------------------------
|
|
||||||
|
|
||||||
fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) {
|
|
||||||
use chrono::Utc;
|
|
||||||
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
||||||
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
|
||||||
id: Uuid::from_u128(n),
|
|
||||||
command: "bash".into(),
|
|
||||||
cwd: std::path::PathBuf::from("/"),
|
|
||||||
env: Default::default(),
|
|
||||||
metadata: HashMap::from([(
|
|
||||||
"name".to_string(),
|
|
||||||
serde_json::Value::String(name.to_string()),
|
|
||||||
)]),
|
|
||||||
worktree: None,
|
|
||||||
status: SessionStatus::Running,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
foreground: None,
|
|
||||||
activity: Default::default(),
|
|
||||||
activity_detail: None,
|
|
||||||
last_activity_at: Utc::now(),
|
|
||||||
activity_since: Utc::now(),
|
|
||||||
current_cwd: None,
|
|
||||||
blocked_work: None,
|
|
||||||
last_focused_at: focused,
|
|
||||||
};
|
|
||||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
||||||
sessions: vec![
|
|
||||||
make(1, "alpha", Some(older)),
|
|
||||||
make(2, "bravo", Some(Utc::now())),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
(Uuid::from_u128(1), Uuid::from_u128(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar
|
|
||||||
/// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`).
|
|
||||||
#[test]
|
|
||||||
fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() {
|
|
||||||
let app = make_app();
|
|
||||||
assert_eq!(
|
|
||||||
app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"),
|
|
||||||
Some(Action::OpenCommandBar)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"),
|
|
||||||
Some(Action::OpenCommandBar)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The help overlay builds its lines from the live keymap, so the bar
|
|
||||||
/// appears there with no second list to maintain.
|
|
||||||
#[test]
|
|
||||||
fn the_help_overlay_lists_the_command_bar() {
|
|
||||||
let app = make_app();
|
|
||||||
let lines = render_help_lines(&app.cfg);
|
|
||||||
assert!(
|
|
||||||
lines
|
|
||||||
.iter()
|
|
||||||
.any(|l| l.contains("ctrl-k") && l.contains("Go to a session")),
|
|
||||||
"{lines:#?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_the_bar_lists_sessions_most_recently_focused_first() {
|
|
||||||
let mut app = make_app();
|
|
||||||
let (alpha, bravo) = seed_two_sessions(&mut app);
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
|
||||||
let bar = app.command_bar.as_ref().unwrap();
|
|
||||||
assert_eq!(bar.query(), "");
|
|
||||||
assert_eq!(
|
|
||||||
bar.rows()
|
|
||||||
.iter()
|
|
||||||
.map(|r| match r.target {
|
|
||||||
BarTarget::Session(id) => id,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![bravo, alpha]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opening from Insert mode leaves the keyboard where it was, so closing
|
|
||||||
/// gives it back — the bar is a view over the app, not a place in it.
|
|
||||||
#[test]
|
|
||||||
fn opening_from_insert_mode_leaves_focus_and_mode_alone() {
|
|
||||||
let mut app = make_app();
|
|
||||||
seed_two_sessions(&mut app);
|
|
||||||
app.state.attach_to_focused(Uuid::from_u128(1));
|
|
||||||
assert_eq!(
|
|
||||||
(app.state.focus, app.state.mode),
|
|
||||||
(Focus::Pane, Mode::Insert)
|
|
||||||
);
|
|
||||||
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
|
||||||
assert_eq!(
|
|
||||||
(app.state.focus, app.state.mode),
|
|
||||||
(Focus::Pane, Mode::Insert)
|
|
||||||
);
|
|
||||||
|
|
||||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
|
||||||
assert!(effects.is_empty());
|
|
||||||
assert!(app.state.overlay().is_none());
|
|
||||||
assert!(app.command_bar.is_none());
|
|
||||||
assert_eq!(
|
|
||||||
(app.state.focus, app.state.mode),
|
|
||||||
(Focus::Pane, Mode::Insert)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter goes through the same open path the sidebar's attach uses.
|
|
||||||
#[test]
|
|
||||||
fn enter_attaches_the_highlighted_session() {
|
|
||||||
let mut app = make_app();
|
|
||||||
let (_alpha, bravo) = seed_two_sessions(&mut app);
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
|
||||||
assert_eq!(effects, vec![Effect::Attach(bravo)]);
|
|
||||||
assert!(app.state.overlay().is_none());
|
|
||||||
assert!(app.command_bar.is_none());
|
|
||||||
assert_eq!(app.state.active_session(), Some(bravo));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Down moves the highlight, so Enter opens the second row instead.
|
|
||||||
#[test]
|
|
||||||
fn the_highlight_moves_before_enter_attaches() {
|
|
||||||
let mut app = make_app();
|
|
||||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
app.handle_overlay_input(&InputKey::Down);
|
|
||||||
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
||||||
assert_eq!(effects, vec![Effect::Attach(alpha)]);
|
|
||||||
assert_eq!(app.state.active_session(), Some(alpha));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Typing goes to the query, not to the keymap: `q` must not quit and `x`
|
|
||||||
/// must not raise a kill confirm while the bar is up.
|
|
||||||
#[test]
|
|
||||||
fn typing_reaches_the_query_rather_than_the_keymap() {
|
|
||||||
let mut app = make_app();
|
|
||||||
seed_two_sessions(&mut app);
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
for c in "bra".chars() {
|
|
||||||
let (decision, _) = app.handle_overlay_input(&InputKey::Char {
|
|
||||||
c,
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
});
|
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
|
||||||
}
|
|
||||||
let bar = app.command_bar.as_ref().unwrap();
|
|
||||||
assert_eq!(bar.query(), "bra");
|
|
||||||
assert_eq!(bar.rows().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a
|
|
||||||
/// preview and the highlight behind it raises no new one.
|
|
||||||
#[test]
|
|
||||||
fn opening_the_bar_discards_a_preview() {
|
|
||||||
let mut app = make_app();
|
|
||||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
||||||
app.state.sidebar_highlight = Some(SidebarItem::Session(alpha));
|
|
||||||
app.state.preview = Some(alpha);
|
|
||||||
|
|
||||||
app.dispatch(Action::OpenCommandBar);
|
|
||||||
assert_eq!(app.state.preview, None);
|
|
||||||
assert_eq!(app.pending_preview, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kill_highlighted_opens_confirm() {
|
fn kill_highlighted_opens_confirm() {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -2971,7 +2737,6 @@ mod tests {
|
|||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3006,7 +2771,6 @@ mod tests {
|
|||||||
activity_since: Utc::now(),
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ mod tests {
|
|||||||
activity_since: chrono::Utc::now(),
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,758 +0,0 @@
|
|||||||
//! 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,9 +65,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("pageup", "ScrollPanePageUp"),
|
("pageup", "ScrollPanePageUp"),
|
||||||
("pagedown", "ScrollPanePageDown"),
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// Chrome
|
||||||
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
|
||||||
// help overlay picks it up from the live keymap.
|
|
||||||
("ctrl-k", "OpenCommandBar"),
|
|
||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
@@ -78,17 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
/// Default Insert-mode bindings — deliberately empty. Every key typed in
|
||||||
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||||
/// it constantly, so binding it here would make it unreachable. The only way
|
/// it constantly, so binding it here would make it unreachable. The only way
|
||||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||||
/// the Insert escape with it.
|
/// the Insert escape with it.
|
||||||
///
|
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||||
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
|
|
||||||
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
|
|
||||||
/// kill-to-end-of-line is given back through the leader (#156).
|
|
||||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
|
|
||||||
|
|
||||||
impl TuiConfig {
|
impl TuiConfig {
|
||||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod command_bar;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemon_link;
|
pub mod daemon_link;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
@@ -14,7 +13,6 @@ pub mod emulators;
|
|||||||
pub mod fast_forward;
|
pub mod fast_forward;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod matcher;
|
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
|
|||||||
@@ -1,750 +0,0 @@
|
|||||||
//! 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],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,9 +86,6 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
|
|
||||||
/// up.
|
|
||||||
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
|
|
||||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||||
/// restart overlay (`docs/RESTART.md`).
|
/// restart overlay (`docs/RESTART.md`).
|
||||||
pub link: &'a crate::daemon_link::LinkStatus,
|
pub link: &'a crate::daemon_link::LinkStatus,
|
||||||
@@ -201,14 +198,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
}
|
}
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
Some(OverlayKind::CommandBar) => {
|
|
||||||
if let Some(bar) = inputs.command_bar {
|
|
||||||
if let Some((x, y)) = bar.render(f, area) {
|
|
||||||
f.set_cursor_position((x, y));
|
|
||||||
}
|
|
||||||
map.modal = Some(area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -439,7 +428,6 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
command_bar: None,
|
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||||
@@ -536,7 +524,6 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
command_bar: None,
|
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -591,7 +578,6 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
command_bar: None,
|
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -633,7 +619,6 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
command_bar: None,
|
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -499,7 +499,6 @@ mod tests {
|
|||||||
activity_since: Utc::now(),
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,7 +1264,6 @@ mod tests {
|
|||||||
activity_since: Utc::now(),
|
activity_since: Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
@@ -52,10 +52,6 @@ pub enum OverlayKind {
|
|||||||
/// so this stays comparable.
|
/// so this stays comparable.
|
||||||
Rename(Uuid),
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
|
|
||||||
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
|
|
||||||
/// rename buffer does — so this stays comparable.
|
|
||||||
CommandBar,
|
|
||||||
Toast(String),
|
Toast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
|||||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
| 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. |
|
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||||
| A throwaway tab | Dashed border, italic label. |
|
| 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. |
|
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||||
| 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. |
|
| 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
|
Rows say it with fill, not with an edge — see the tier ladder in
|
||||||
|
|||||||
+1
-1
@@ -189,7 +189,7 @@ furthest down wins the paint.
|
|||||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
| 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) |
|
| 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 |
|
| 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) |
|
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
|
||||||
|
|
||||||
The web ladder is written as one ordered run of equal-specificity rules in
|
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
|
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||||
|
|||||||
+7
-22
@@ -5,7 +5,6 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
@@ -14,15 +13,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
// The pane's half of the command registry (#148): plain Escape hands the
|
|
||||||
// keyboard back to the session list, the pair to the list's Tab.
|
|
||||||
const paneKeys = new CommandSet(
|
|
||||||
defineCommands({
|
|
||||||
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
|
||||||
}),
|
|
||||||
{ preventDefault: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -50,10 +40,6 @@ export interface AttachConnection {
|
|||||||
onUnmount(): void;
|
onUnmount(): void;
|
||||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||||
focusTerminal(): void;
|
focusTerminal(): void;
|
||||||
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
|
||||||
* handling — the leader's literal Ctrl-K escape hatch
|
|
||||||
* (docs/COMMAND-BAR.md). */
|
|
||||||
sendInput(data: string): void;
|
|
||||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -70,6 +56,8 @@ export interface AttachOptions {
|
|||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** What the header shows about the session (name / command). Optional — falls
|
/** What the header shows about the session (name / command). Optional — falls
|
||||||
* back to a short session id when the session isn't known yet. */
|
* back to a short session id when the session isn't known yet. */
|
||||||
export interface AttachInfo {
|
export interface AttachInfo {
|
||||||
@@ -222,12 +210,12 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plain Escape hands the keyboard back to the session list instead of the
|
// Plain Escape hands the keyboard back to the session list instead of the
|
||||||
// shell/app. `false` tells xterm to skip its own handling of this event —
|
// shell/app. `false` tells xterm to skip its own handling of this event.
|
||||||
// which is also why the set doesn't `preventDefault`: swallowing here is
|
|
||||||
// xterm's return value, not the browser's.
|
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
if (e.type !== "keydown") return true;
|
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
||||||
return !paneKeys.handle(e);
|
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
||||||
|
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||||
@@ -510,9 +498,6 @@ export function createAttachConnection(
|
|||||||
focusTerminal() {
|
focusTerminal() {
|
||||||
term.focus();
|
term.focus();
|
||||||
},
|
},
|
||||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
|
||||||
// bar already take.
|
|
||||||
sendInput,
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
+19
-26
@@ -2,8 +2,7 @@ import { escapeHtml } from "./html.js";
|
|||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openForceKillDialog } from "./killDialog.js";
|
import { openForceKillDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { toggleHelp } from "./help.js";
|
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { InTheWay, Session } from "./types.js";
|
import type { InTheWay, Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
@@ -670,30 +669,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// The session list's half of the command registry (#148). One set per
|
treeHost.addEventListener("keydown", (e) => {
|
||||||
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
// labels and chords still come from the one registry.
|
// The inline rename field lives inside a row and owns its own keys.
|
||||||
const listKeys = new CommandSet(
|
if (isTypingTarget(e.target)) return;
|
||||||
defineCommands({
|
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||||
"list.next": () => moveNav(1),
|
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||||
"list.prev": () => moveNav(-1),
|
else if (e.key === "Enter") activateNav();
|
||||||
"list.activate": () => activateNav(),
|
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||||
"list.toggleFolder": () => toggleHighlightedFolder(),
|
else if (e.key === "c") toggleHighlightedFolder();
|
||||||
"list.rename": () => {
|
else if (e.key === "x") {
|
||||||
const hit = highlightedSession();
|
const hit = highlightedSession();
|
||||||
if (hit) beginRename(hit.row, hit.session);
|
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||||
},
|
} else if (e.key === "r") {
|
||||||
"list.kill": () => {
|
const hit = highlightedSession();
|
||||||
const hit = highlightedSession();
|
if (hit) beginRename(hit.row, hit.session);
|
||||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
} else return;
|
||||||
},
|
e.preventDefault();
|
||||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The inline rename field lives inside a row and owns its own keys; the set's
|
|
||||||
// plain-key rule keeps this walk out of it.
|
|
||||||
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
|
||||||
|
|
||||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||||
let renderedSignature: string | null = null;
|
let renderedSignature: string | null = null;
|
||||||
|
|||||||
@@ -1,544 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
|
||||||
import {
|
|
||||||
barGroups,
|
|
||||||
barRows,
|
|
||||||
closeCommandBar,
|
|
||||||
commandBarCandidates,
|
|
||||||
dedupeCommands,
|
|
||||||
directoryBarCandidates,
|
|
||||||
directoryCandidate,
|
|
||||||
isCommandBarOpen,
|
|
||||||
openCommandBar,
|
|
||||||
orderSessionsForBar,
|
|
||||||
parseBarQuery,
|
|
||||||
sessionBarCandidates,
|
|
||||||
sessionCandidate,
|
|
||||||
SPAWN_DIRECTORY_EVENT,
|
|
||||||
} from "./commandBar.js";
|
|
||||||
import { COMMAND_SPECS, type Command } 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" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sessions only: the mixed list's command half is the sigil describe's
|
|
||||||
// business, and the registry is whatever another test file happened to bind.
|
|
||||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
|
||||||
return openCommandBar(
|
|
||||||
over ?? { sessions, homeDir: "/home/me", commands: [], 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", commands: [] });
|
|
||||||
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", commands: [], 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", commands: [], onOpenSession });
|
|
||||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
|
||||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
|
||||||
expect(isCommandBarOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- the sigils (#150) ----
|
|
||||||
|
|
||||||
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
|
||||||
return { id, label, group: "Test", keys, scope: "global", run };
|
|
||||||
}
|
|
||||||
|
|
||||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
function rowKeys(): string[][] {
|
|
||||||
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
|
||||||
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parsing the query line", () => {
|
|
||||||
it("reads a sigil only in first position", () => {
|
|
||||||
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
|
||||||
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
|
||||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
|
||||||
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
|
||||||
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
|
||||||
// The case that matters: the second `~` of a directory query reaches the
|
|
||||||
// matcher.
|
|
||||||
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
|
||||||
expect(parseBarQuery(">").sigil).toBe(">");
|
|
||||||
expect(parseBarQuery("").sigil).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("command candidates", () => {
|
|
||||||
it("dedupes by action, not by chord", () => {
|
|
||||||
const run = () => {};
|
|
||||||
const kill = command("a.kill", "Kill the session", ["x"], run);
|
|
||||||
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
|
||||||
const other = command("c.rename", "Rename the session", ["r"]);
|
|
||||||
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches on the label only, and keeps the chord for display", () => {
|
|
||||||
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
|
||||||
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
|
||||||
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("directory candidates", () => {
|
|
||||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
|
||||||
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
|
||||||
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
|
||||||
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
|
||||||
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
|
||||||
"/home/me/dev/api",
|
|
||||||
"/tmp/scratch",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the sigils in the overlay", () => {
|
|
||||||
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
|
||||||
const ran: string[] = [];
|
|
||||||
const commands = [
|
|
||||||
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
|
||||||
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
|
||||||
];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
ran.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
|
||||||
return openCommandBar({
|
|
||||||
sessions,
|
|
||||||
homeDir: "/home/me",
|
|
||||||
commands,
|
|
||||||
onOpenSession: () => {},
|
|
||||||
loadDirectories: async () => [],
|
|
||||||
listDirEntries: async () => [],
|
|
||||||
onSpawnDirectory: () => {},
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
|
||||||
open();
|
|
||||||
type(">");
|
|
||||||
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
|
||||||
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
|
||||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches a command on its label and never on its chord", () => {
|
|
||||||
open();
|
|
||||||
type(">kill");
|
|
||||||
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
|
||||||
// `n` and `x` are chords, not label text: querying one finds nothing that
|
|
||||||
// the label does not also justify.
|
|
||||||
type(">ctrlx");
|
|
||||||
expect(rowNames()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs the highlighted command on Enter", () => {
|
|
||||||
open();
|
|
||||||
type(">kill");
|
|
||||||
press("Enter");
|
|
||||||
expect(ran).toEqual(["list.kill"]);
|
|
||||||
expect(isCommandBarOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
|
||||||
open();
|
|
||||||
type("s");
|
|
||||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
|
||||||
"Sessions",
|
|
||||||
"Commands",
|
|
||||||
]);
|
|
||||||
type(">spawn");
|
|
||||||
expect(rowNames()).toEqual(["Open the spawn form"]);
|
|
||||||
// Backspacing the sigil away is ordinary editing.
|
|
||||||
type("spawn");
|
|
||||||
expect(rowNames()).toContain("Open the spawn form");
|
|
||||||
type("");
|
|
||||||
expect(rowNames()).toEqual(["api"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists recent dirs then projects under `~`", async () => {
|
|
||||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
|
||||||
type("~");
|
|
||||||
await flush();
|
|
||||||
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
|
||||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
|
||||||
const asked: string[] = [];
|
|
||||||
open({
|
|
||||||
loadDirectories: async () => ["/home/me/notes"],
|
|
||||||
listDirEntries: async (dir) => {
|
|
||||||
asked.push(dir);
|
|
||||||
return ["maestro", "api"];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// The second `~` is a literal query character and reaches the matcher.
|
|
||||||
type("~~/dev/mae");
|
|
||||||
await flush();
|
|
||||||
expect(asked).toEqual(["~/dev/"]);
|
|
||||||
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spawns in the highlighted directory on Enter", async () => {
|
|
||||||
const spawned: string[] = [];
|
|
||||||
open({
|
|
||||||
loadDirectories: async () => ["~/dev/api"],
|
|
||||||
onSpawnDirectory: (path) => spawned.push(path),
|
|
||||||
});
|
|
||||||
type("~api");
|
|
||||||
await flush();
|
|
||||||
press("Enter");
|
|
||||||
expect(spawned).toEqual(["/home/me/dev/api"]);
|
|
||||||
expect(isCommandBarOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
|
||||||
const seen: string[] = [];
|
|
||||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
|
||||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
|
||||||
openCommandBar({
|
|
||||||
sessions,
|
|
||||||
homeDir: "/home/me",
|
|
||||||
commands,
|
|
||||||
onOpenSession: () => {},
|
|
||||||
loadDirectories: async () => ["/home/me/dev/api"],
|
|
||||||
listDirEntries: async () => [],
|
|
||||||
});
|
|
||||||
type("~");
|
|
||||||
await flush();
|
|
||||||
press("Enter");
|
|
||||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
|
||||||
expect(seen).toEqual(["/home/me/dev/api"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the focus round-trip over a pane (#152)", () => {
|
|
||||||
function terminalTextarea(): HTMLTextAreaElement {
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.className = "xterm-helper-textarea";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
return ta;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("hands the keyboard back to the terminal that had it", () => {
|
|
||||||
const ta = terminalTextarea();
|
|
||||||
ta.focus();
|
|
||||||
openCommandBar({ sessions: [], commands: [] });
|
|
||||||
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
|
||||||
press("Escape");
|
|
||||||
expect(document.activeElement).toBe(ta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports no focus change to the daemon, opening or closing", () => {
|
|
||||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
try {
|
|
||||||
const ta = terminalTextarea();
|
|
||||||
ta.focus();
|
|
||||||
openCommandBar({ sessions: [], commands: [] });
|
|
||||||
press("Escape");
|
|
||||||
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
|
|
||||||
String(url).includes("/focus"),
|
|
||||||
);
|
|
||||||
expect(focusCalls).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
|
||||||
// list, opened from the sidebar, closed with Escape.
|
|
||||||
//
|
|
||||||
// Three kinds live here: sessions and commands, ranked together by default,
|
|
||||||
// and directories, which need the `~` sigil. The action page and the
|
|
||||||
// terminal-focus binding land in #151-#152.
|
|
||||||
|
|
||||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
|
||||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
|
||||||
import {
|
|
||||||
matchAndRank,
|
|
||||||
type CandidateKind,
|
|
||||||
type CommandCandidate,
|
|
||||||
type DirectoryCandidate,
|
|
||||||
type MatchRange,
|
|
||||||
type MatchResult,
|
|
||||||
type SessionCandidate,
|
|
||||||
} from "./matcher.js";
|
|
||||||
import { splitPath } from "./paths.js";
|
|
||||||
import { splitCommand } from "./toolIcon.js";
|
|
||||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
|
||||||
|
|
||||||
/** A matcher candidate that remembers what 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 CommandBarCandidate = CommandCandidate & { command: Command };
|
|
||||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
|
||||||
* ranks; `path` is the one a spawn is made in. */
|
|
||||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
|
||||||
|
|
||||||
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
|
||||||
export type BarResult = MatchResult<BarCandidate>;
|
|
||||||
|
|
||||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
|
||||||
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
|
||||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
|
||||||
|
|
||||||
/** One kind's slice of the ranked list, in rank order. */
|
|
||||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
|
||||||
kind: CandidateKind;
|
|
||||||
title: string;
|
|
||||||
results: MatchResult<C>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
|
||||||
session: "Sessions",
|
|
||||||
command: "Commands",
|
|
||||||
directory: "Directories",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- the sigils ----
|
|
||||||
|
|
||||||
export type BarSigil = ">" | "~";
|
|
||||||
|
|
||||||
export interface BarQuery {
|
|
||||||
/** The sigil in force, or null for the mixed list. */
|
|
||||||
sigil: BarSigil | null;
|
|
||||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
|
||||||
query: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a raw query line into its sigil and the query proper.
|
|
||||||
*
|
|
||||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
|
||||||
* literal query character, which is what a directory query with a second `~`
|
|
||||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
|
||||||
* the first character goes and the query is a mixed one again. */
|
|
||||||
export function parseBarQuery(raw: string): BarQuery {
|
|
||||||
const first = raw[0];
|
|
||||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
|
||||||
return { sigil: null, query: raw };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
|
||||||
* action — the registry already carries its aliases on one entry, but nothing
|
|
||||||
* stops two entries running the same handler, and listed naively that is two
|
|
||||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
|
||||||
* displayed, so a command with two of them is still one row. */
|
|
||||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
|
||||||
const seenRun = new Set<Command["run"]>();
|
|
||||||
const seenLabel = new Set<string>();
|
|
||||||
const kept: Command[] = [];
|
|
||||||
for (const command of commands) {
|
|
||||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
|
||||||
seenRun.add(command.run);
|
|
||||||
seenLabel.add(command.label);
|
|
||||||
kept.push(command);
|
|
||||||
}
|
|
||||||
return kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
|
||||||
* field: the chord is displayed beside the row, never matched, because
|
|
||||||
* matching it would make `>` results depend on a user's rebinds. */
|
|
||||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
|
||||||
return dedupeCommands(commands).map((command) => ({
|
|
||||||
kind: "command",
|
|
||||||
command,
|
|
||||||
fields: { label: command.label },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
|
||||||
* a path the operator typed or `/api/dirs` completed. */
|
|
||||||
export function expandHome(path: string, homeDir?: string): string {
|
|
||||||
if (!homeDir) return path;
|
|
||||||
if (path === "~") return homeDir;
|
|
||||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
|
||||||
const absolute = expandHome(path, homeDir);
|
|
||||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
|
||||||
* first mention of each — recent dirs (newest first) ahead of the project
|
|
||||||
* list, which is the empty-query order the contract asks for. */
|
|
||||||
export function directoryBarCandidates(
|
|
||||||
paths: readonly string[],
|
|
||||||
homeDir?: string,
|
|
||||||
): DirectoryBarCandidate[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: DirectoryBarCandidate[] = [];
|
|
||||||
for (const path of paths) {
|
|
||||||
if (path === "") continue;
|
|
||||||
const candidate = directoryCandidate(path, homeDir);
|
|
||||||
if (seen.has(candidate.path)) continue;
|
|
||||||
seen.add(candidate.path);
|
|
||||||
out.push(candidate);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The directory list the bar starts from: what the spawn form already knows —
|
|
||||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
|
||||||
* fails. */
|
|
||||||
export async function loadBarDirectories(): Promise<string[]> {
|
|
||||||
const [recents, discovered] = await Promise.all([
|
|
||||||
recentDirs()
|
|
||||||
.then((r) => r.dirs.map((d) => d.cwd))
|
|
||||||
.catch(() => [] as string[]),
|
|
||||||
fetchProjects()
|
|
||||||
.then((list) => list.map((p) => p.path))
|
|
||||||
.catch(() => [] as string[]),
|
|
||||||
]);
|
|
||||||
return [...recents, ...discovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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<C extends BarCandidate>(
|
|
||||||
results: readonly MatchResult<C>[],
|
|
||||||
): BarGroup<C>[] {
|
|
||||||
const groups: BarGroup<C>[] = [];
|
|
||||||
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<C extends BarCandidate>(
|
|
||||||
query: string,
|
|
||||||
candidates: readonly C[],
|
|
||||||
): MatchResult<C>[] {
|
|
||||||
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 name line of a row: the session's name, the command's label, or the
|
|
||||||
* directory's path. */
|
|
||||||
function rowName(candidate: BarCandidate): string {
|
|
||||||
switch (candidate.kind) {
|
|
||||||
case "session":
|
|
||||||
return candidate.fields.name;
|
|
||||||
case "command":
|
|
||||||
return candidate.fields.label;
|
|
||||||
case "directory":
|
|
||||||
return candidate.fields.path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The secondary line under a session row: the field the match was found in
|
|
||||||
* when that was not the name, so a session found through its directory says
|
|
||||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
|
||||||
* directory's path is already the name. */
|
|
||||||
function metaText(result: BarResult): string | undefined {
|
|
||||||
if (result.candidate.kind !== "session") return 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 candidate = result.candidate;
|
|
||||||
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.kind = candidate.kind;
|
|
||||||
if (candidate.kind === "session") {
|
|
||||||
row.dataset.sessionId = candidate.session.id;
|
|
||||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
|
||||||
} else if (candidate.kind === "command") {
|
|
||||||
row.dataset.commandId = candidate.command.id;
|
|
||||||
} else {
|
|
||||||
row.dataset.path = candidate.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.createElement("span");
|
|
||||||
name.className = "cmdbar-row-name";
|
|
||||||
const nameText = rowName(candidate);
|
|
||||||
const nameMatched =
|
|
||||||
result.field === "name" || result.field === "label" || result.field === "path";
|
|
||||||
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
|
||||||
row.appendChild(name);
|
|
||||||
|
|
||||||
// The chord is teaching material, not a match target: it is rendered from
|
|
||||||
// the live registry entry and never fed to the matcher.
|
|
||||||
if (candidate.kind === "command") {
|
|
||||||
const keys = document.createElement("span");
|
|
||||||
keys.className = "cmdbar-row-keys";
|
|
||||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
|
||||||
const kbd = document.createElement("kbd");
|
|
||||||
kbd.textContent = cap;
|
|
||||||
keys.appendChild(kbd);
|
|
||||||
}
|
|
||||||
row.appendChild(keys);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
|
||||||
mixed: "Go to a session…",
|
|
||||||
">": "Run a command…",
|
|
||||||
"~": "Spawn in a directory…",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 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;
|
|
||||||
/** The command registry to list under `>`. Defaults to every bound command,
|
|
||||||
* in registry order. */
|
|
||||||
commands?: readonly Command[];
|
|
||||||
/** The directory list behind `~`, fetched once on first use. */
|
|
||||||
loadDirectories?: () => Promise<string[]>;
|
|
||||||
/** One directory's subdirectories, for filling the list in as a path is
|
|
||||||
* typed. Defaults to `GET /api/dirs`. */
|
|
||||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
|
||||||
/** Spawn a session in the highlighted directory. Defaults to raising
|
|
||||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
|
||||||
* spawn. */
|
|
||||||
onSpawnDirectory?: (path: 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 spawnDirectory =
|
|
||||||
opts.onSpawnDirectory ??
|
|
||||||
((path: string) =>
|
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
|
||||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
|
||||||
const listEntries =
|
|
||||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
|
||||||
|
|
||||||
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
|
||||||
opts.sessions,
|
|
||||||
opts.homeDir,
|
|
||||||
);
|
|
||||||
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
|
||||||
opts.commands ?? publishedCommands(),
|
|
||||||
);
|
|
||||||
|
|
||||||
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, commands and directories");
|
|
||||||
input.setAttribute("autocomplete", "off");
|
|
||||||
input.spellcheck = false;
|
|
||||||
input.placeholder = PLACEHOLDERS.mixed;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
|
||||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
|
||||||
let directories: DirectoryBarCandidate[] = [];
|
|
||||||
let directoriesRequested = false;
|
|
||||||
let probed: DirectoryBarCandidate[] = [];
|
|
||||||
let probedDir: string | null = null;
|
|
||||||
const listings = new Map<string, string[]>();
|
|
||||||
|
|
||||||
function alive(): boolean {
|
|
||||||
return current?.el === el;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch the recents-and-projects list the first time `~` is used. */
|
|
||||||
function ensureDirectories(): void {
|
|
||||||
if (directoriesRequested) return;
|
|
||||||
directoriesRequested = true;
|
|
||||||
loadDirectories()
|
|
||||||
.then((paths) => {
|
|
||||||
directories = directoryBarCandidates(paths, opts.homeDir);
|
|
||||||
if (alive()) render();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* no directories is a shorter list, not an error state */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
|
||||||
* the directory it names is listed and its children join the candidates, so
|
|
||||||
* `~` reaches a directory that was never spawned in. */
|
|
||||||
function probeDirectories(query: string): void {
|
|
||||||
const { dir } = splitPath(query);
|
|
||||||
if (dir === "") {
|
|
||||||
probed = [];
|
|
||||||
probedDir = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dir === probedDir) return;
|
|
||||||
probedDir = dir;
|
|
||||||
const cached = listings.get(dir);
|
|
||||||
if (cached) {
|
|
||||||
probed = directoryBarCandidates(
|
|
||||||
cached.map((entry) => `${dir}${entry}`),
|
|
||||||
opts.homeDir,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
probed = [];
|
|
||||||
listEntries(dir)
|
|
||||||
.then((entries) => {
|
|
||||||
listings.set(dir, entries);
|
|
||||||
if (!alive() || probedDir !== dir) return;
|
|
||||||
probed = directoryBarCandidates(
|
|
||||||
entries.map((entry) => `${dir}${entry}`),
|
|
||||||
opts.homeDir,
|
|
||||||
);
|
|
||||||
render();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* an unreadable directory contributes nothing */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
|
||||||
* list is mixed — sessions and commands together — except on an empty query,
|
|
||||||
* where the bar is a session switcher. */
|
|
||||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
|
||||||
switch (parsed.sigil) {
|
|
||||||
case ">":
|
|
||||||
return [...commandCandidates];
|
|
||||||
case "~": {
|
|
||||||
ensureDirectories();
|
|
||||||
probeDirectories(parsed.query);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return [...directories, ...probed].filter((c) =>
|
|
||||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return parsed.query === ""
|
|
||||||
? [...sessionCandidates]
|
|
||||||
: [...sessionCandidates, ...commandCandidates];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const parsed = parseBarQuery(input.value);
|
|
||||||
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
|
||||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
|
||||||
// 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)) {
|
|
||||||
// While a sigil is in force there is one kind, so the header is dropped.
|
|
||||||
if (parsed.sigil === null) {
|
|
||||||
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 candidate = result.candidate;
|
|
||||||
close();
|
|
||||||
switch (candidate.kind) {
|
|
||||||
case "session":
|
|
||||||
openSession(candidate.session.id);
|
|
||||||
return;
|
|
||||||
case "command":
|
|
||||||
candidate.command.run();
|
|
||||||
return;
|
|
||||||
case "directory":
|
|
||||||
spawnDirectory(candidate.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
COMMAND_SPECS,
|
|
||||||
CommandSet,
|
|
||||||
DEFAULT_LEADER,
|
|
||||||
canonicalizeKey,
|
|
||||||
defineCommands,
|
|
||||||
isBarTypingTarget,
|
|
||||||
isTerminalTextarea,
|
|
||||||
isTypingTarget,
|
|
||||||
keyCaps,
|
|
||||||
publishedCommands,
|
|
||||||
} from "./commands.js";
|
|
||||||
|
|
||||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
|
||||||
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
|
|
||||||
}
|
|
||||||
|
|
||||||
function el(html: string): HTMLElement {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.innerHTML = html;
|
|
||||||
return d.firstElementChild as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
|
|
||||||
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
|
|
||||||
const e = key(k, init);
|
|
||||||
Object.defineProperty(e, "target", { value: target });
|
|
||||||
return set.handle(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("command specs", () => {
|
|
||||||
it("has a unique id and at least one chord per command", () => {
|
|
||||||
const ids = COMMAND_SPECS.map((s) => s.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
for (const spec of COMMAND_SPECS) {
|
|
||||||
expect(spec.keys.length).toBeGreaterThan(0);
|
|
||||||
expect(spec.label.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binds each chord once within a scope", () => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const spec of COMMAND_SPECS) {
|
|
||||||
for (const k of spec.keys) {
|
|
||||||
const slot = `${spec.scope}:${k}`;
|
|
||||||
expect(seen.has(slot)).toBe(false);
|
|
||||||
seen.add(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a handler for an id that isn't in the registry", () => {
|
|
||||||
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries every spec field onto the defined command", () => {
|
|
||||||
const [cmd] = defineCommands({ "spawn.open": () => {} });
|
|
||||||
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("canonicalizeKey", () => {
|
|
||||||
it("spells keys the way the TUI's InputKey::canonical does", () => {
|
|
||||||
expect(canonicalizeKey(key("n"))).toBe("n");
|
|
||||||
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
|
|
||||||
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
|
|
||||||
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
|
|
||||||
expect(canonicalizeKey(key("Escape"))).toBe("esc");
|
|
||||||
expect(canonicalizeKey(key("Enter"))).toBe("enter");
|
|
||||||
expect(canonicalizeKey(key("Tab"))).toBe("tab");
|
|
||||||
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
|
|
||||||
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("folds a modified letter to lower case with an explicit shift-", () => {
|
|
||||||
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
|
||||||
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has no canonical form for a modifier pressed alone", () => {
|
|
||||||
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
|
|
||||||
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("keyCaps", () => {
|
|
||||||
it("renders one cap per modifier and key", () => {
|
|
||||||
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
|
|
||||||
expect(keyCaps("enter")).toEqual(["Enter"]);
|
|
||||||
expect(keyCaps("j")).toEqual(["j"]);
|
|
||||||
expect(keyCaps("N")).toEqual(["N"]);
|
|
||||||
expect(keyCaps("?")).toEqual(["?"]);
|
|
||||||
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CommandSet", () => {
|
|
||||||
it("runs the command bound to a chord, and swallows the browser default", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
|
||||||
const e = key("n");
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(e.defaultPrevented).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an unbound key alone", () => {
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
|
||||||
const e = key("q");
|
|
||||||
expect(set.handle(e)).toBe(false);
|
|
||||||
expect(e.defaultPrevented).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a modified key as a different binding", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
|
||||||
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
|
|
||||||
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
|
|
||||||
expect(run).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs every alias of a command", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "list.next": run }));
|
|
||||||
set.handle(key("j"));
|
|
||||||
set.handle(key("ArrowDown"));
|
|
||||||
expect(run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
|
||||||
const plain = vi.fn();
|
|
||||||
const chorded = vi.fn();
|
|
||||||
const set = new CommandSet(
|
|
||||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
|
||||||
);
|
|
||||||
const input = el("<input>");
|
|
||||||
expect(pressOn(set, input, "n")).toBe(false);
|
|
||||||
expect(plain).not.toHaveBeenCalled();
|
|
||||||
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
|
||||||
expect(chorded).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
|
||||||
preventDefault: false,
|
|
||||||
});
|
|
||||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
||||||
const e = key("Escape");
|
|
||||||
Object.defineProperty(e, "target", { value: textarea });
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
// xterm is told to swallow by the return value, not by the browser default.
|
|
||||||
expect(e.defaultPrevented).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the leader", () => {
|
|
||||||
it("defaults to the TUI's ctrl-a", () => {
|
|
||||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
|
||||||
leader: DEFAULT_LEADER,
|
|
||||||
});
|
|
||||||
const e = key("a", { ctrlKey: true });
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(e.defaultPrevented).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("swallows the key after the leader rather than running its plain binding", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
|
||||||
leader: DEFAULT_LEADER,
|
|
||||||
});
|
|
||||||
set.handle(key("a", { ctrlKey: true }));
|
|
||||||
expect(set.handle(key("n"))).toBe(true);
|
|
||||||
expect(run).not.toHaveBeenCalled();
|
|
||||||
// The next key is an ordinary one again.
|
|
||||||
set.handle(key("n"));
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forgets a held leader once the chord timeout passes", () => {
|
|
||||||
const run = vi.fn();
|
|
||||||
let now = 0;
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
|
||||||
leader: DEFAULT_LEADER,
|
|
||||||
chordTimeoutMs: 1000,
|
|
||||||
now: () => now,
|
|
||||||
});
|
|
||||||
set.handle(key("a", { ctrlKey: true }));
|
|
||||||
now = 1500;
|
|
||||||
set.handle(key("n"));
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves select-all alone in a real form field", () => {
|
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
|
||||||
leader: DEFAULT_LEADER,
|
|
||||||
});
|
|
||||||
const input = el("<input>");
|
|
||||||
const e = key("a", { ctrlKey: true });
|
|
||||||
Object.defineProperty(e, "target", { value: input });
|
|
||||||
expect(set.handle(e)).toBe(false);
|
|
||||||
expect(e.defaultPrevented).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("publishing", () => {
|
|
||||||
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
|
|
||||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
|
||||||
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
|
||||||
const ids = publishedCommands().map((c) => c.id);
|
|
||||||
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps published commands in registry order", () => {
|
|
||||||
const order = COMMAND_SPECS.map((s) => s.id);
|
|
||||||
const ids = publishedCommands().map((c) => c.id);
|
|
||||||
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isTypingTarget", () => {
|
|
||||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
|
||||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
|
||||||
expect(isTypingTarget(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isTerminalTextarea", () => {
|
|
||||||
it("recognises xterm's offscreen textarea, and nothing else", () => {
|
|
||||||
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
|
|
||||||
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
|
|
||||||
expect(isTerminalTextarea(el("<input>"))).toBe(false);
|
|
||||||
expect(isTerminalTextarea(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isBarTypingTarget", () => {
|
|
||||||
it("holds the bar's keys out of real form fields", () => {
|
|
||||||
expect(isBarTypingTarget(el("<input>"))).toBe(true);
|
|
||||||
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
|
|
||||||
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
|
|
||||||
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
|
|
||||||
expect(isBarTypingTarget(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ctrl-k over a focused pane (#152)", () => {
|
|
||||||
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
|
|
||||||
|
|
||||||
/** The global listener's routing: the bar's set only sees a key that is not
|
|
||||||
* aimed at a real form field. */
|
|
||||||
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
|
|
||||||
if (isBarTypingTarget(target)) return false;
|
|
||||||
return pressOn(set, target, k, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("opens the bar when the terminal has the keyboard", () => {
|
|
||||||
const open = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
|
||||||
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
|
|
||||||
expect(open).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the spawn form's inputs their own Ctrl-K", () => {
|
|
||||||
const open = vi.fn();
|
|
||||||
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
|
||||||
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
|
|
||||||
expect(open).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the literal 0x0B after the leader, without opening the bar", () => {
|
|
||||||
const open = vi.fn();
|
|
||||||
const literal = vi.fn();
|
|
||||||
const bar = new CommandSet(defineCommands({ "bar.open": open }));
|
|
||||||
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
|
|
||||||
leader: DEFAULT_LEADER,
|
|
||||||
});
|
|
||||||
const target = terminal();
|
|
||||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
|
||||||
const e = key("k", { ctrlKey: true });
|
|
||||||
Object.defineProperty(e, "target", { value: target });
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(literal).toHaveBeenCalledTimes(1);
|
|
||||||
// The leader's set consumed the key, so the bar's set never sees it.
|
|
||||||
expect(open).not.toHaveBeenCalled();
|
|
||||||
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
|
|
||||||
expect(open).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binds the escape hatch behind the default leader", () => {
|
|
||||||
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
|
|
||||||
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
// The web client's command registry (#148). One entry per command — a stable
|
|
||||||
// id, a plain-language label, the key chord it answers to, and a `run()` — so
|
|
||||||
// there is exactly one list of what the client can do. The `?` overlay renders
|
|
||||||
// from it (`help.ts`), and every keybinding is dispatched through it; a command
|
|
||||||
// added here is bound and documented in the same edit.
|
|
||||||
//
|
|
||||||
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
|
|
||||||
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
|
|
||||||
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
|
|
||||||
// docs/COMMAND-BAR.md describe one key model for both clients.
|
|
||||||
|
|
||||||
/** Where a command's keys are listened for. `global` is the window, `list` the
|
|
||||||
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
|
|
||||||
* dialog while it is up. */
|
|
||||||
export type CommandScope = "global" | "list" | "pane" | "overlay";
|
|
||||||
|
|
||||||
export interface CommandSpec {
|
|
||||||
/** Stable identifier. Never rendered; safe to bind config and tests to. */
|
|
||||||
id: string;
|
|
||||||
/** Plain-language label — what the `?` overlay and (later) the command bar
|
|
||||||
* show. */
|
|
||||||
label: string;
|
|
||||||
/** Help-overlay section. Groups render in the order they first appear here. */
|
|
||||||
group: string;
|
|
||||||
/** Canonical chords that run it. The first is the one shown to the operator;
|
|
||||||
* the rest are aliases (`j` and `down`). */
|
|
||||||
keys: string[];
|
|
||||||
scope: CommandScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Command extends CommandSpec {
|
|
||||||
run: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
|
||||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
|
||||||
export const DEFAULT_LEADER = "ctrl-a";
|
|
||||||
|
|
||||||
/** Every command the web client has. The single list this issue exists to
|
|
||||||
* create: labels, chords and grouping live here and nowhere else. */
|
|
||||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|
||||||
{
|
|
||||||
id: "spawn.open",
|
|
||||||
label: "Open the spawn form",
|
|
||||||
group: "Sessions",
|
|
||||||
keys: ["n"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spawn.fastForward",
|
|
||||||
label: "Spawn a session with every default resolved",
|
|
||||||
group: "Sessions",
|
|
||||||
keys: ["N"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bar.open",
|
|
||||||
label: "Open the command bar",
|
|
||||||
group: "Sessions",
|
|
||||||
keys: ["ctrl-k"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pane.literalCtrlK",
|
|
||||||
label: "Send a literal Ctrl-K to the pane",
|
|
||||||
group: "Pane",
|
|
||||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "focus.toggle",
|
|
||||||
label: "Move the keyboard between the session list and the pane",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["ctrl-shift-h"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.next",
|
|
||||||
label: "Highlight the next session",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["j", "down"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.prev",
|
|
||||||
label: "Highlight the previous session",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["k", "up"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.activate",
|
|
||||||
label: "Open the highlighted session, or expand a folder",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["enter"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.toggleFolder",
|
|
||||||
label: "Collapse or expand the highlighted folder",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["c"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.rename",
|
|
||||||
label: "Rename the highlighted session",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["r"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.kill",
|
|
||||||
label: "Kill the highlighted session",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["x"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list.focusPane",
|
|
||||||
label: "Send the keyboard to the pane",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["tab"],
|
|
||||||
scope: "list",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pane.focusList",
|
|
||||||
label: "Hand the keyboard back to the session list, from the pane",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["esc"],
|
|
||||||
scope: "pane",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "help.toggle",
|
|
||||||
label: "Show or hide this help",
|
|
||||||
group: "Help",
|
|
||||||
keys: ["?"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "help.close",
|
|
||||||
label: "Close the help overlay or an open menu",
|
|
||||||
group: "Help",
|
|
||||||
keys: ["esc"],
|
|
||||||
scope: "overlay",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "debug.toggleHud",
|
|
||||||
label: "Toggle the debug HUD",
|
|
||||||
group: "Debug",
|
|
||||||
keys: ["ctrl-shift-d"],
|
|
||||||
scope: "global",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
|
||||||
|
|
||||||
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
|
||||||
* test failure rather than a key that silently does nothing. */
|
|
||||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
|
||||||
return Object.entries(handlers).map(([id, run]) => {
|
|
||||||
const spec = SPEC_BY_ID.get(id);
|
|
||||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
|
||||||
return { ...spec, run };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- what the help overlay renders ----
|
|
||||||
|
|
||||||
const published = new Map<string, Command>();
|
|
||||||
|
|
||||||
/** Record commands as bound. Re-registering an id replaces it, so a second
|
|
||||||
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
|
|
||||||
export function publishCommands(cmds: Command[]): void {
|
|
||||||
for (const c of cmds) published.set(c.id, c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every bound command, in `COMMAND_SPECS` order. */
|
|
||||||
export function publishedCommands(): Command[] {
|
|
||||||
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- key canonicalisation ----
|
|
||||||
|
|
||||||
const NAMED: Record<string, string> = {
|
|
||||||
Escape: "esc",
|
|
||||||
Enter: "enter",
|
|
||||||
Tab: "tab",
|
|
||||||
Backspace: "backspace",
|
|
||||||
ArrowUp: "up",
|
|
||||||
ArrowDown: "down",
|
|
||||||
ArrowLeft: "left",
|
|
||||||
ArrowRight: "right",
|
|
||||||
Home: "home",
|
|
||||||
End: "end",
|
|
||||||
PageUp: "pageup",
|
|
||||||
PageDown: "pagedown",
|
|
||||||
Delete: "delete",
|
|
||||||
" ": "space",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The canonical string for one keydown. Mirrors the TUI's
|
|
||||||
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
|
|
||||||
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
|
|
||||||
* only written when another modifier is present and the char was folded to
|
|
||||||
* lower case. */
|
|
||||||
export function canonicalizeKey(e: KeyboardEvent): string | null {
|
|
||||||
if (!e.key) return null;
|
|
||||||
// Modifier keys pressed alone are never a binding.
|
|
||||||
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
|
|
||||||
const named = NAMED[e.key];
|
|
||||||
if (named === "tab" && e.shiftKey) return "backtab";
|
|
||||||
let out = "";
|
|
||||||
if (e.ctrlKey) out += "ctrl-";
|
|
||||||
if (e.altKey) out += "alt-";
|
|
||||||
if (e.metaKey) out += "meta-";
|
|
||||||
if (named) {
|
|
||||||
if (e.shiftKey) out += "shift-";
|
|
||||||
return out + named;
|
|
||||||
}
|
|
||||||
if (e.key.length === 1) {
|
|
||||||
if (out === "") return e.key;
|
|
||||||
if (e.shiftKey) out += "shift-";
|
|
||||||
return out + e.key.toLowerCase();
|
|
||||||
}
|
|
||||||
if (e.shiftKey) out += "shift-";
|
|
||||||
return out + e.key.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
|
||||||
|
|
||||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
|
||||||
* chords are the ones that stay deaf inside form fields. */
|
|
||||||
function isChorded(chord: string): boolean {
|
|
||||||
return chord.includes(" ") || MOD_PREFIX.test(chord);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
|
||||||
* hidden textarea xterm types into. Every plain single-key binding is deaf in
|
|
||||||
* the same places. */
|
|
||||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
||||||
const el = target as HTMLElement | null;
|
|
||||||
if (!el || typeof el.tagName !== "string") return false;
|
|
||||||
return (
|
|
||||||
el.tagName === "INPUT" ||
|
|
||||||
el.tagName === "TEXTAREA" ||
|
|
||||||
el.tagName === "SELECT" ||
|
|
||||||
el.isContentEditable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
|
||||||
* also where the leader has to work — a pane is exactly where the operator
|
|
||||||
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
|
||||||
export function isTerminalTextarea(target: EventTarget | null): boolean {
|
|
||||||
const el = target as HTMLElement | null;
|
|
||||||
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True where the command bar's own keys must stay out of the way: a real form
|
|
||||||
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
|
|
||||||
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
|
|
||||||
* the spawn form's inputs keep their own Ctrl-K. */
|
|
||||||
export function isBarTypingTarget(target: EventTarget | null): boolean {
|
|
||||||
return isTypingTarget(target) && !isTerminalTextarea(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- display ----
|
|
||||||
|
|
||||||
const CAPS: Record<string, string> = {
|
|
||||||
ctrl: "Ctrl",
|
|
||||||
alt: "Alt",
|
|
||||||
meta: "Meta",
|
|
||||||
shift: "Shift",
|
|
||||||
esc: "Esc",
|
|
||||||
enter: "Enter",
|
|
||||||
tab: "Tab",
|
|
||||||
backtab: "Shift Tab",
|
|
||||||
backspace: "Backspace",
|
|
||||||
up: "Up",
|
|
||||||
down: "Down",
|
|
||||||
left: "Left",
|
|
||||||
right: "Right",
|
|
||||||
home: "Home",
|
|
||||||
end: "End",
|
|
||||||
pageup: "PgUp",
|
|
||||||
pagedown: "PgDn",
|
|
||||||
delete: "Del",
|
|
||||||
space: "Space",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
|
|
||||||
* multi-key chord separates its keys with a `then` cap. */
|
|
||||||
export function keyCaps(chord: string): string[] {
|
|
||||||
const caps: string[] = [];
|
|
||||||
for (const [i, key] of chord.split(" ").entries()) {
|
|
||||||
if (i > 0) caps.push("then");
|
|
||||||
const parts = key.split("-");
|
|
||||||
// A bare `-` key splits into two empties; treat it as the key itself.
|
|
||||||
if (parts.every((p) => p === "")) {
|
|
||||||
caps.push("-");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const last = parts.pop() as string;
|
|
||||||
const mods = parts.filter((p) => p !== "");
|
|
||||||
for (const m of mods) caps.push(CAPS[m] ?? m);
|
|
||||||
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
|
|
||||||
}
|
|
||||||
return caps;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- dispatch ----
|
|
||||||
|
|
||||||
/** How long a partial chord waits for its next key — the TUI's
|
|
||||||
* `chord_timeout_ms` default. */
|
|
||||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
|
||||||
|
|
||||||
export interface CommandSetOptions {
|
|
||||||
/** Canonical leader key. A set given one holds every leader press as a
|
|
||||||
* pending chord, whether or not anything is bound behind it yet. */
|
|
||||||
leader?: string | null;
|
|
||||||
chordTimeoutMs?: number;
|
|
||||||
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
|
|
||||||
* handler, which swallows by returning `false` to xterm instead. */
|
|
||||||
preventDefault?: boolean;
|
|
||||||
/** Clock, for tests. */
|
|
||||||
now?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A group of commands listening on one surface. Resolves a keydown to a
|
|
||||||
* command the way the TUI's `ChordState` does: hold while the sequence is a
|
|
||||||
* strict prefix of some binding, otherwise resolve and dispatch. */
|
|
||||||
export class CommandSet {
|
|
||||||
private readonly byChord = new Map<string, Command>();
|
|
||||||
private readonly leader: string | null;
|
|
||||||
private readonly timeout: number;
|
|
||||||
private readonly preventDefault: boolean;
|
|
||||||
private readonly now: () => number;
|
|
||||||
private held: string[] = [];
|
|
||||||
private lastPress = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly commands: Command[],
|
|
||||||
opts: CommandSetOptions = {},
|
|
||||||
) {
|
|
||||||
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
|
|
||||||
this.leader = opts.leader ?? null;
|
|
||||||
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
|
|
||||||
this.preventDefault = opts.preventDefault ?? true;
|
|
||||||
this.now = opts.now ?? (() => Date.now());
|
|
||||||
publishCommands(commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether any binding starts with `candidate` and continues. */
|
|
||||||
private isPrefix(candidate: string): boolean {
|
|
||||||
const marker = `${candidate} `;
|
|
||||||
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
|
|
||||||
* held as a partial chord. */
|
|
||||||
handle(e: KeyboardEvent): boolean {
|
|
||||||
const key = canonicalizeKey(e);
|
|
||||||
if (key === null) return false;
|
|
||||||
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
|
|
||||||
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
|
|
||||||
this.lastPress = this.now();
|
|
||||||
|
|
||||||
// The leader is a prefix by construction, so it stops being select-all
|
|
||||||
// before anything is bound behind it (#149 binds the first chord).
|
|
||||||
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
|
|
||||||
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
|
|
||||||
this.held = [key];
|
|
||||||
if (this.preventDefault) e.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isPrefix(candidate)) {
|
|
||||||
this.held.push(key);
|
|
||||||
if (this.preventDefault) e.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasChord = this.held.length > 0;
|
|
||||||
this.held = [];
|
|
||||||
const cmd = this.byChord.get(candidate);
|
|
||||||
if (!cmd) {
|
|
||||||
// A chord that resolved to nothing is still consumed: the leader took
|
|
||||||
// the key, so it must not also fall through to a plain binding.
|
|
||||||
return wasChord;
|
|
||||||
}
|
|
||||||
// Plain single-key bindings are deaf where typing happens; chorded ones are
|
|
||||||
// not, and neither are the pane's and the overlay's, which are only fed
|
|
||||||
// from the surface that owns them.
|
|
||||||
if (
|
|
||||||
!isChorded(candidate) &&
|
|
||||||
cmd.scope !== "pane" &&
|
|
||||||
cmd.scope !== "overlay" &&
|
|
||||||
isTypingTarget(e.target)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.preventDefault) e.preventDefault();
|
|
||||||
cmd.run();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,23 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
|
||||||
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
|
||||||
// the pool reaches the focused connection with them.
|
|
||||||
const sent: { id: string; data: string }[] = [];
|
|
||||||
vi.mock("./attach.js", () => ({
|
|
||||||
createAttachConnection: (id: string) => ({
|
|
||||||
sessionId: id,
|
|
||||||
el: document.createElement("div"),
|
|
||||||
onMount() {},
|
|
||||||
onUnmount() {},
|
|
||||||
focusTerminal() {},
|
|
||||||
sendInput(data: string) {
|
|
||||||
sent.push({ id, data });
|
|
||||||
},
|
|
||||||
dispose() {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -102,26 +85,3 @@ describe("ConnectionPool.setFocus", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ConnectionPool.sendToFocused", () => {
|
|
||||||
it("types the bytes into the focused session's PTY", () => {
|
|
||||||
mockFetch();
|
|
||||||
sent.length = 0;
|
|
||||||
const pool = new ConnectionPool();
|
|
||||||
const pane = document.createElement("div");
|
|
||||||
pool.mount(A, pane);
|
|
||||||
pool.setFocus(A);
|
|
||||||
|
|
||||||
expect(pool.sendToFocused("\x0b")).toBe(true);
|
|
||||||
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op with nothing focused", () => {
|
|
||||||
mockFetch();
|
|
||||||
sent.length = 0;
|
|
||||||
const pool = new ConnectionPool();
|
|
||||||
|
|
||||||
expect(pool.sendToFocused("\x0b")).toBe(false);
|
|
||||||
expect(sent).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -159,18 +159,6 @@ export class ConnectionPool {
|
|||||||
this.cache.get(this.focusedId)?.focusTerminal();
|
this.cache.get(this.focusedId)?.focusTerminal();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
|
||||||
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
|
||||||
* is a no-op over the welcome pane rather than an error
|
|
||||||
* (docs/COMMAND-BAR.md). */
|
|
||||||
sendToFocused(data: string): boolean {
|
|
||||||
if (this.focusedId === null) return false;
|
|
||||||
const conn = this.cache.get(this.focusedId);
|
|
||||||
if (!conn) return false;
|
|
||||||
conn.sendInput(data);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fully disconnect + forget a session. */
|
/** Fully disconnect + forget a session. */
|
||||||
close(id: Uuid): void {
|
close(id: Uuid): void {
|
||||||
const conn = this.cache.get(id);
|
const conn = this.cache.get(id);
|
||||||
|
|||||||
+6
-5
@@ -2,8 +2,6 @@
|
|||||||
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
|
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
|
||||||
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
|
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
|
||||||
|
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "maestro:hud";
|
const STORAGE_KEY = "maestro:hud";
|
||||||
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
|
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
|
||||||
|
|
||||||
@@ -33,9 +31,12 @@ export function toggleHud(): void {
|
|||||||
setHudEnabled(!isHudEnabled());
|
setHudEnabled(!isHudEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() }));
|
|
||||||
|
|
||||||
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
|
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
|
||||||
export function installHudKeybind(): void {
|
export function installHudKeybind(): void {
|
||||||
window.addEventListener("keydown", (e) => hudKeys.handle(e));
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleHud();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-18
@@ -1,8 +1,14 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { keyCaps, publishedCommands } from "./commands.js";
|
import {
|
||||||
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
SHORTCUTS,
|
||||||
|
closeHelp,
|
||||||
|
installHelpKeybind,
|
||||||
|
isHelpOpen,
|
||||||
|
isTypingTarget,
|
||||||
|
openHelp,
|
||||||
|
} from "./help.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installHelpKeybind();
|
installHelpKeybind();
|
||||||
@@ -24,24 +30,14 @@ function rows(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("help overlay", () => {
|
describe("help overlay", () => {
|
||||||
it("renders exactly the commands the registry has bound, grouped", () => {
|
it("renders every registered shortcut, grouped", () => {
|
||||||
openHelp();
|
openHelp();
|
||||||
const groups = groupedCommands();
|
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
|
||||||
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
|
||||||
groups.map((g) => g.title),
|
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
|
||||||
|
expect(document.querySelectorAll(".help-desc")).toHaveLength(
|
||||||
|
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
|
||||||
);
|
);
|
||||||
expect(rows()).toEqual(
|
|
||||||
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
|
|
||||||
);
|
|
||||||
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
|
||||||
groups.flatMap((g) => g.items.map((c) => c.label)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names each row by its command id, so a row can't drift from its command", () => {
|
|
||||||
openHelp();
|
|
||||||
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
|
|
||||||
.toEqual(publishedCommands().map((c) => c.id));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens on ? and closes on a second ?", () => {
|
it("opens on ? and closes on a second ?", () => {
|
||||||
@@ -98,3 +94,19 @@ describe("help overlay", () => {
|
|||||||
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isTypingTarget", () => {
|
||||||
|
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||||
|
const el = (html: string) => {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.innerHTML = html;
|
||||||
|
return d.firstElementChild as HTMLElement;
|
||||||
|
};
|
||||||
|
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||||
|
expect(isTypingTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+79
-37
@@ -1,15 +1,69 @@
|
|||||||
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
||||||
* overlay. It renders the command registry (`commands.ts`) — the same list
|
* overlay. One registry drives both the rendering and what we claim is bound,
|
||||||
* that dispatches the keys — so a binding can never be documented as one
|
* so a binding added to `main.ts` without an entry here reads as missing. */
|
||||||
* thing and do another. */
|
|
||||||
|
|
||||||
import {
|
export interface Shortcut {
|
||||||
CommandSet,
|
/** Keycaps, rendered left to right as separate <kbd>. */
|
||||||
defineCommands,
|
keys: string[];
|
||||||
keyCaps,
|
description: string;
|
||||||
publishedCommands,
|
}
|
||||||
type Command,
|
|
||||||
} from "./commands.js";
|
export interface ShortcutGroup {
|
||||||
|
title: string;
|
||||||
|
items: Shortcut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUTS: ShortcutGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Sessions",
|
||||||
|
items: [
|
||||||
|
{ keys: ["n"], description: "Open the spawn form" },
|
||||||
|
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Session list",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
keys: ["Ctrl", "Shift", "H"],
|
||||||
|
description: "Move the keyboard between the session list and the pane",
|
||||||
|
},
|
||||||
|
{ keys: ["j"], description: "Highlight the next session" },
|
||||||
|
{ keys: ["k"], description: "Highlight the previous session" },
|
||||||
|
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||||
|
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
|
||||||
|
{ keys: ["r"], description: "Rename the highlighted session" },
|
||||||
|
{ keys: ["x"], description: "Kill the highlighted session" },
|
||||||
|
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||||
|
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Help",
|
||||||
|
items: [
|
||||||
|
{ keys: ["?"], description: "Show or hide this help" },
|
||||||
|
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Debug",
|
||||||
|
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||||
|
* hidden textarea xterm types into. Shared with `main.ts` so every global
|
||||||
|
* single-key binding is deaf in the same places. */
|
||||||
|
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
if (!el || typeof el.tagName !== "string") return false;
|
||||||
|
return (
|
||||||
|
el.tagName === "INPUT" ||
|
||||||
|
el.tagName === "TEXTAREA" ||
|
||||||
|
el.tagName === "SELECT" ||
|
||||||
|
el.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
@@ -54,7 +108,7 @@ export function openHelp(): HTMLElement {
|
|||||||
head.append(title, close);
|
head.append(title, close);
|
||||||
panel.appendChild(head);
|
panel.appendChild(head);
|
||||||
|
|
||||||
for (const group of groupedCommands()) {
|
for (const group of SHORTCUTS) {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "help-group";
|
section.className = "help-group";
|
||||||
const heading = document.createElement("h3");
|
const heading = document.createElement("h3");
|
||||||
@@ -62,20 +116,19 @@ export function openHelp(): HTMLElement {
|
|||||||
heading.textContent = group.title;
|
heading.textContent = group.title;
|
||||||
const list = document.createElement("dl");
|
const list = document.createElement("dl");
|
||||||
list.className = "help-list";
|
list.className = "help-list";
|
||||||
for (const cmd of group.items) {
|
for (const item of group.items) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "help-row";
|
row.className = "help-row";
|
||||||
row.dataset.commandId = cmd.id;
|
|
||||||
const keys = document.createElement("dt");
|
const keys = document.createElement("dt");
|
||||||
keys.className = "help-keys";
|
keys.className = "help-keys";
|
||||||
for (const key of keyCaps(cmd.keys[0])) {
|
for (const key of item.keys) {
|
||||||
const cap = document.createElement("kbd");
|
const cap = document.createElement("kbd");
|
||||||
cap.textContent = key;
|
cap.textContent = key;
|
||||||
keys.appendChild(cap);
|
keys.appendChild(cap);
|
||||||
}
|
}
|
||||||
const desc = document.createElement("dd");
|
const desc = document.createElement("dd");
|
||||||
desc.className = "help-desc";
|
desc.className = "help-desc";
|
||||||
desc.textContent = cmd.label;
|
desc.textContent = item.description;
|
||||||
row.append(keys, desc);
|
row.append(keys, desc);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -100,26 +153,6 @@ export function openHelp(): HTMLElement {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The registry's commands, in registry order, bucketed into the help
|
|
||||||
* overlay's sections. Groups appear in the order their first command does. */
|
|
||||||
export function groupedCommands(): { title: string; items: Command[] }[] {
|
|
||||||
const groups: { title: string; items: Command[] }[] = [];
|
|
||||||
for (const cmd of publishedCommands()) {
|
|
||||||
let group = groups.find((g) => g.title === cmd.group);
|
|
||||||
if (!group) {
|
|
||||||
group = { title: cmd.group, items: [] };
|
|
||||||
groups.push(group);
|
|
||||||
}
|
|
||||||
group.items.push(cmd);
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
|
|
||||||
// fed keys while the dialog is up.
|
|
||||||
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
|
|
||||||
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
|
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
||||||
@@ -128,7 +161,16 @@ export function installHelpKeybind(): void {
|
|||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (open && overlayKeys.handle(e)) return;
|
if (e.key === "Escape" && open) {
|
||||||
helpKeys.handle(e);
|
e.preventDefault();
|
||||||
|
closeHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shift is how `?` is typed on most layouts; the others would be a
|
||||||
|
// different binding entirely.
|
||||||
|
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
if (isTypingTarget(e.target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
toggleHelp();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-133
@@ -6,9 +6,7 @@ import { renderSidebar } from "./catalog.js";
|
|||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
|
|
||||||
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
|
||||||
import { DaemonLink } from "./daemonLink.js";
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { installRestartOverlay } from "./restartOverlay.js";
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
gitRoot,
|
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@@ -82,15 +79,6 @@ async function bootApp(): Promise<void> {
|
|||||||
|
|
||||||
const store = new CatalogStore();
|
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
|
// 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
|
// 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.
|
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
||||||
@@ -338,16 +326,6 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
|
|||||||
// a new branch named after that name. Anything unresolvable (no projects
|
// a new branch named after that name. Anything unresolvable (no projects
|
||||||
// known yet, no command, no generated name) opens the form pre-filled with
|
// known yet, no command, no generated name) opens the form pre-filled with
|
||||||
// what did resolve instead.
|
// what did resolve instead.
|
||||||
async function spawnPresets(): Promise<SpawnPreset[]> {
|
|
||||||
try {
|
|
||||||
const info = await getDaemonInfo();
|
|
||||||
if (info.presets?.length) return info.presets;
|
|
||||||
} catch {
|
|
||||||
/* keep fallbacks */
|
|
||||||
}
|
|
||||||
return FALLBACK_PRESETS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestFastForward(): Promise<void> {
|
async function requestFastForward(): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, name] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
@@ -355,7 +333,13 @@ async function requestFastForward(): Promise<void> {
|
|||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
const presets = await spawnPresets();
|
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||||
|
try {
|
||||||
|
const info = await getDaemonInfo();
|
||||||
|
if (info.presets?.length) presets = info.presets;
|
||||||
|
} catch {
|
||||||
|
/* keep fallbacks */
|
||||||
|
}
|
||||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
@@ -377,122 +361,38 @@ async function requestFastForward(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
|
||||||
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
// Ignored while typing anywhere (form fields, the terminal's hidden
|
||||||
// project. A directory the project scan never saw stands in as its own project;
|
// textarea, search) so an attached session's keys are never hijacked.
|
||||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
document.addEventListener("keydown", (e) => {
|
||||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
if (isTypingTarget(e.target)) return;
|
||||||
const [discovered, name] = await Promise.all([
|
// The help overlay owns the keyboard while it is up.
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
if (isHelpOpen()) return;
|
||||||
fetchNames(1)
|
if (e.key === "n") {
|
||||||
.then((batch) => batch.names[0])
|
e.preventDefault();
|
||||||
.catch(() => undefined),
|
requestOpenSpawn();
|
||||||
]);
|
} else if (e.key === "N") {
|
||||||
const presets = await spawnPresets();
|
e.preventDefault();
|
||||||
const known = discovered.find((p) => p.path === path);
|
void requestFastForward();
|
||||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
|
||||||
const resolved = resolveFastForward([project], presets, name, path);
|
|
||||||
const req = buildFastForwardRequest(resolved);
|
|
||||||
if (req) {
|
|
||||||
const inRepo =
|
|
||||||
known !== undefined ||
|
|
||||||
(await gitRoot(path)
|
|
||||||
.then((r) => !!r.root)
|
|
||||||
.catch(() => false));
|
|
||||||
if (!inRepo) delete req.worktree;
|
|
||||||
try {
|
|
||||||
mountSpawned(await createSession(req));
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// A failed POST degrades to the pre-filled form, never a dead row.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
openSpawnModal({
|
|
||||||
seed: fastForwardSeed(resolved),
|
|
||||||
onSuccess: (session) => mountSpawned(session),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
|
||||||
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The global half of the command registry (#148). Two sets, because they
|
|
||||||
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
|
||||||
// the plain keys go deaf behind it while the focus chord does not — an
|
|
||||||
// attached terminal must never be able to trap the focus keys.
|
|
||||||
//
|
|
||||||
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
|
||||||
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
|
|
||||||
// hidden textarea.
|
|
||||||
const spawnKeys = new CommandSet(
|
|
||||||
defineCommands({
|
|
||||||
"spawn.open": () => requestOpenSpawn(),
|
|
||||||
"spawn.fastForward": () => void requestFastForward(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
||||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||||
// the focus back out of it.
|
// the focus back out of it.
|
||||||
//
|
window.addEventListener("keydown", (e) => {
|
||||||
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||||
// key as a partial chord and takes select-all off it, so the escape hatch
|
if (e.key !== "H" && e.key !== "h") return;
|
||||||
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
e.preventDefault();
|
||||||
const focusKeys = new CommandSet(
|
if (sidebar.hasListFocus()) {
|
||||||
defineCommands({
|
pool.focusTerminal();
|
||||||
"focus.toggle": () => {
|
} else {
|
||||||
if (sidebar.hasListFocus()) {
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
pool.focusTerminal();
|
sidebar.focusList();
|
||||||
} else {
|
}
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
});
|
||||||
sidebar.focusList();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
|
|
||||||
// away from every pane, and leader ctrl-k gives it back as the literal
|
|
||||||
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
|
||||||
// bar's, so the chord never opens the bar.
|
|
||||||
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
|
||||||
}),
|
|
||||||
{ leader: DEFAULT_LEADER },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
|
||||||
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
|
||||||
// spawn form's inputs — keeps its own Ctrl+K.
|
|
||||||
if (!isBarTypingTarget(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
|
// 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`.
|
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
matchAndRank,
|
|
||||||
type CommandCandidate,
|
|
||||||
type DirectoryCandidate,
|
|
||||||
type MatchCandidate,
|
|
||||||
type MatchResult,
|
|
||||||
type SessionCandidate,
|
|
||||||
} from "./matcher.js";
|
|
||||||
|
|
||||||
function session(
|
|
||||||
name: string,
|
|
||||||
directory?: string,
|
|
||||||
command?: string,
|
|
||||||
tool?: string,
|
|
||||||
): SessionCandidate {
|
|
||||||
return { kind: "session", fields: { name, directory, command, tool } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function command(label: string): CommandCandidate {
|
|
||||||
return { kind: "command", fields: { label } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function directory(path: string): DirectoryCandidate {
|
|
||||||
return { kind: "directory", fields: { path } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function label(result: MatchResult): string {
|
|
||||||
switch (result.candidate.kind) {
|
|
||||||
case "session":
|
|
||||||
return `session:${result.candidate.fields.name}`;
|
|
||||||
case "command":
|
|
||||||
return `command:${result.candidate.fields.label}`;
|
|
||||||
case "directory":
|
|
||||||
return `directory:${result.candidate.fields.path}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function positions(result: MatchResult): number[] {
|
|
||||||
return result.ranges.flatMap(([start, end]) =>
|
|
||||||
Array.from({ length: end - start }, (_, offset) => start + offset),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectResults(
|
|
||||||
query: string,
|
|
||||||
candidates: MatchCandidate[],
|
|
||||||
expected: Array<[label: string, score: number, field?: string, positions?: number[]]>,
|
|
||||||
): void {
|
|
||||||
const results = matchAndRank(query, candidates);
|
|
||||||
|
|
||||||
expect(results.map((result) => [label(result), result.score])).toEqual(
|
|
||||||
expected.map(([candidateLabel, score]) => [candidateLabel, score]),
|
|
||||||
);
|
|
||||||
expect(results.map((result) => result.field)).toEqual(
|
|
||||||
expected.map(([, , field]) => field),
|
|
||||||
);
|
|
||||||
expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("matchAndRank fixtures", () => {
|
|
||||||
it("F1: an empty query keeps input order", () => {
|
|
||||||
expectResults(
|
|
||||||
"",
|
|
||||||
[
|
|
||||||
session("kill", "~/dev/api", "bash"),
|
|
||||||
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
|
|
||||||
command("Kill selected session"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
["session:kill", 0],
|
|
||||||
["session:killer-app", 0],
|
|
||||||
["command:Kill selected session", 0],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F2: an exact session name beats a prefix and kind breaks a tie", () => {
|
|
||||||
expectResults(
|
|
||||||
"kill",
|
|
||||||
[
|
|
||||||
session("kill", "~/dev/api", "bash"),
|
|
||||||
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
|
|
||||||
command("Kill selected session"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
["session:kill", 178, "name", [0, 1, 2, 3]],
|
|
||||||
["session:killer-app", 146, "name", [0, 1, 2, 3]],
|
|
||||||
["command:Kill selected session", 146, "label", [0, 1, 2, 3]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F3: path segment boundaries beat mid-segment matches", () => {
|
|
||||||
expectResults(
|
|
||||||
"foo",
|
|
||||||
[directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")],
|
|
||||||
[
|
|
||||||
["directory:~/foo", 78, "path", [2, 3, 4]],
|
|
||||||
["directory:~/dev/foobar", 74, "path", [6, 7, 8]],
|
|
||||||
["directory:~/src/myfoo", 56, "path", [8, 9, 10]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F4: a query matching nothing returns no candidates", () => {
|
|
||||||
expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F5: the highest-scoring embedding can differ from the greedy embedding", () => {
|
|
||||||
expectResults(
|
|
||||||
"mtu",
|
|
||||||
[session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")],
|
|
||||||
[
|
|
||||||
["session:mtui-scratch", 122, "name", [0, 1, 2]],
|
|
||||||
["session:maestro-tui", 91, "name", [0, 8, 9]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F6: a name beats a weighted directory match", () => {
|
|
||||||
expectResults(
|
|
||||||
"api",
|
|
||||||
[session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")],
|
|
||||||
[
|
|
||||||
["session:api", 154, "name", [0, 1, 2]],
|
|
||||||
["session:worker", 50, "directory", [6, 7, 8]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F7: matching is case-insensitive and field length breaks a tie", () => {
|
|
||||||
expectResults(
|
|
||||||
"api",
|
|
||||||
[session("API-Gateway"), session("apiary")],
|
|
||||||
[
|
|
||||||
["session:apiary", 122, "name", [0, 1, 2]],
|
|
||||||
["session:API-Gateway", 122, "name", [0, 1, 2]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F8: the exact tool field beats the command field", () => {
|
|
||||||
expectResults(
|
|
||||||
"claude",
|
|
||||||
[session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")],
|
|
||||||
[["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F9: a separator earns a word-boundary bonus", () => {
|
|
||||||
expectResults(
|
|
||||||
"ws",
|
|
||||||
[session("websearch"), session("web-search")],
|
|
||||||
[
|
|
||||||
["session:web-search", 71, "name", [0, 4]],
|
|
||||||
["session:websearch", 56, "name", [0, 3]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F10: command labels rank by boundary and initial gap", () => {
|
|
||||||
expectResults(
|
|
||||||
"se",
|
|
||||||
[command("Kill selected session"), command("Create a session"), command("Show help")],
|
|
||||||
[
|
|
||||||
["command:Show help", 53, "label", [0, 6]],
|
|
||||||
["command:Kill selected session", 51, "label", [5, 6]],
|
|
||||||
["command:Create a session", 47, "label", [9, 10]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F11: directory matches separate on their initial gap", () => {
|
|
||||||
expectResults(
|
|
||||||
"dev",
|
|
||||||
[
|
|
||||||
session("api", "~/dev/api", "bash"),
|
|
||||||
session("notes", "~/documents/dev-notes", "nvim"),
|
|
||||||
session("scratch", "~/srv/devops", "bash"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
["session:api", 54, "directory", [2, 3, 4]],
|
|
||||||
["session:scratch", 50, "directory", [6, 7, 8]],
|
|
||||||
["session:notes", 44, "directory", [12, 13, 14]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F12: an exact field beats a prefix", () => {
|
|
||||||
expectResults(
|
|
||||||
"quit",
|
|
||||||
[command("Quit Maestro"), command("Quit")],
|
|
||||||
[
|
|
||||||
["command:Quit", 178, "label", [0, 1, 2, 3]],
|
|
||||||
["command:Quit Maestro", 146, "label", [0, 1, 2, 3]],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
export type CandidateKind = "session" | "command" | "directory";
|
|
||||||
|
|
||||||
export interface SessionCandidate {
|
|
||||||
kind: "session";
|
|
||||||
fields: {
|
|
||||||
name: string;
|
|
||||||
directory?: string;
|
|
||||||
command?: string;
|
|
||||||
tool?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandCandidate {
|
|
||||||
kind: "command";
|
|
||||||
fields: { label: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectoryCandidate {
|
|
||||||
kind: "directory";
|
|
||||||
fields: { path: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate;
|
|
||||||
export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path";
|
|
||||||
export type MatchRange = readonly [start: number, end: number];
|
|
||||||
|
|
||||||
export interface MatchResult<C extends MatchCandidate = MatchCandidate> {
|
|
||||||
candidate: C;
|
|
||||||
score: number;
|
|
||||||
field: MatchFieldName | undefined;
|
|
||||||
ranges: MatchRange[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Field {
|
|
||||||
name: MatchFieldName;
|
|
||||||
text: string;
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Embedding {
|
|
||||||
score: number;
|
|
||||||
positions: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RankedResult<C extends MatchCandidate> extends MatchResult<C> {
|
|
||||||
matchedText: string;
|
|
||||||
inputIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIND_ORDER: Record<CandidateKind, number> = {
|
|
||||||
session: 0,
|
|
||||||
command: 1,
|
|
||||||
directory: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
function fields(candidate: MatchCandidate): Field[] {
|
|
||||||
switch (candidate.kind) {
|
|
||||||
case "session": {
|
|
||||||
const values: Array<[MatchFieldName, string | undefined, number]> = [
|
|
||||||
["name", candidate.fields.name, 0],
|
|
||||||
["directory", candidate.fields.directory, -24],
|
|
||||||
["command", candidate.fields.command, -32],
|
|
||||||
["tool", candidate.fields.tool, -40],
|
|
||||||
];
|
|
||||||
return values.flatMap(([name, text, weight]) =>
|
|
||||||
text === undefined ? [] : [{ name, text, weight }],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "command":
|
|
||||||
return [{ name: "label", text: candidate.fields.label, weight: 0 }];
|
|
||||||
case "directory":
|
|
||||||
return [{ name: "path", text: candidate.fields.path, weight: 0 }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeQuery(query: string): string {
|
|
||||||
return query.toLowerCase().replace(/\s/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundaryBonus(text: string, position: number): number {
|
|
||||||
if (position === 0) return 18;
|
|
||||||
|
|
||||||
const previous = text[position - 1];
|
|
||||||
if ("/\\-_ .:".includes(previous)) return 16;
|
|
||||||
|
|
||||||
const current = text[position];
|
|
||||||
const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase();
|
|
||||||
const previousIsLowercase =
|
|
||||||
previous !== previous.toUpperCase() && previous === previous.toLowerCase();
|
|
||||||
if (isUppercase && previousIsLowercase) return 12;
|
|
||||||
|
|
||||||
const isDigit = current >= "0" && current <= "9";
|
|
||||||
const previousIsDigit = previous >= "0" && previous <= "9";
|
|
||||||
if (isDigit && !previousIsDigit) return 12;
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contribution(text: string, position: number, previousPosition: number): number {
|
|
||||||
const gap = position - previousPosition - 1;
|
|
||||||
return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean {
|
|
||||||
if (!incumbent || candidate.score !== incumbent.score) {
|
|
||||||
return !incumbent || candidate.score > incumbent.score;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < candidate.positions.length; index++) {
|
|
||||||
if (candidate.positions[index] !== incumbent.positions[index]) {
|
|
||||||
return candidate.positions[index] < incumbent.positions[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bestEmbedding(query: string, text: string): Embedding | undefined {
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
let previousStates = new Map<number, Embedding>();
|
|
||||||
|
|
||||||
for (let queryIndex = 0; queryIndex < query.length; queryIndex++) {
|
|
||||||
const states = new Map<number, Embedding>();
|
|
||||||
for (let position = 0; position < lowerText.length; position++) {
|
|
||||||
if (lowerText[position] !== query[queryIndex]) continue;
|
|
||||||
|
|
||||||
if (queryIndex === 0) {
|
|
||||||
states.set(position, {
|
|
||||||
score: contribution(text, position, -1),
|
|
||||||
positions: [position],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [previousPosition, previous] of previousStates) {
|
|
||||||
if (previousPosition >= position) continue;
|
|
||||||
const candidate = {
|
|
||||||
score: previous.score + contribution(text, position, previousPosition),
|
|
||||||
positions: [...previous.positions, position],
|
|
||||||
};
|
|
||||||
if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (states.size === 0) return undefined;
|
|
||||||
previousStates = states;
|
|
||||||
}
|
|
||||||
|
|
||||||
let best: Embedding | undefined;
|
|
||||||
for (const embedding of previousStates.values()) {
|
|
||||||
if (isBetterEmbedding(embedding, best)) best = embedding;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRanges(positions: number[]): MatchRange[] {
|
|
||||||
const ranges: MatchRange[] = [];
|
|
||||||
for (const position of positions) {
|
|
||||||
const previous = ranges[ranges.length - 1];
|
|
||||||
if (previous && previous[1] === position) {
|
|
||||||
ranges[ranges.length - 1] = [previous[0], position + 1];
|
|
||||||
} else {
|
|
||||||
ranges.push([position, position + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareByCodePoint(left: string, right: string): number {
|
|
||||||
const leftPoints = Array.from(left, (character) => character.codePointAt(0)!);
|
|
||||||
const rightPoints = Array.from(right, (character) => character.codePointAt(0)!);
|
|
||||||
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
||||||
for (let index = 0; index < length; index++) {
|
|
||||||
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
|
|
||||||
}
|
|
||||||
return leftPoints.length - rightPoints.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchAndRank<C extends MatchCandidate>(
|
|
||||||
query: string,
|
|
||||||
candidates: readonly C[],
|
|
||||||
): MatchResult<C>[] {
|
|
||||||
const normalizedQuery = normalizeQuery(query);
|
|
||||||
if (normalizedQuery === "") {
|
|
||||||
return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: RankedResult<C>[] = [];
|
|
||||||
candidates.forEach((candidate, inputIndex) => {
|
|
||||||
let best:
|
|
||||||
| { field: Field; score: number; positions: number[] }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
for (const field of fields(candidate)) {
|
|
||||||
const embedding = bestEmbedding(normalizedQuery, field.text);
|
|
||||||
if (!embedding) continue;
|
|
||||||
|
|
||||||
const compactText = field.text.toLowerCase().replace(/\s/g, "");
|
|
||||||
const adjustment =
|
|
||||||
compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0;
|
|
||||||
const score = embedding.score + adjustment + field.weight;
|
|
||||||
if (!best || score > best.score) best = { field, score, positions: embedding.positions };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best) {
|
|
||||||
results.push({
|
|
||||||
candidate,
|
|
||||||
score: best.score,
|
|
||||||
field: best.field.name,
|
|
||||||
ranges: toRanges(best.positions),
|
|
||||||
matchedText: best.field.text,
|
|
||||||
inputIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
results.sort(
|
|
||||||
(left, right) =>
|
|
||||||
right.score - left.score ||
|
|
||||||
KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] ||
|
|
||||||
left.matchedText.length - right.matchedText.length ||
|
|
||||||
compareByCodePoint(left.matchedText, right.matchedText) ||
|
|
||||||
left.inputIndex - right.inputIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges }));
|
|
||||||
}
|
|
||||||
+4
-143
@@ -1202,8 +1202,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||||
session that is already open still reads as provisional. */
|
session that is already open still reads as provisional — except the session
|
||||||
.tree-leaf:where(.is-previewing) {
|
already mounted in the pane (tier 4). Previewing that one changes nothing on
|
||||||
|
screen, so its accent stays put instead of blinking out under the pointer. */
|
||||||
|
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
|
||||||
background: var(--preview-tint);
|
background: var(--preview-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,147 +1425,6 @@ input.leaf-rename:focus {
|
|||||||
color: var(--muted);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A command row's chord, shown so the bar teaches the keymap. */
|
|
||||||
.cmdbar-row-keys {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmdbar-row-keys kbd {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-11);
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--r-1);
|
|
||||||
padding: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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
|
/* 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. */
|
state word itself is never spelled out here; the colour carries it. */
|
||||||
.card-line2 {
|
.card-line2 {
|
||||||
|
|||||||
@@ -50,12 +50,6 @@ export interface Session {
|
|||||||
* create one. `base_repo` is the repository's main checkout, which is what
|
* create one. `base_repo` is the repository's main checkout, which is what
|
||||||
* the session tree groups by (#99). */
|
* the session tree groups by (#99). */
|
||||||
worktree?: SessionWorktree | null;
|
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). */
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||||
|
|||||||
Reference in New Issue
Block a user