Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c004b5d1b6 | ||
|
|
c51d5b8f7b | ||
|
|
1b2b392710 | ||
|
|
72644dd564 | ||
|
|
fa846000e7 | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 | ||
|
|
59ede15d2b | ||
|
|
331039df11 | ||
|
|
151438094e | ||
|
|
c1c283c0f8 | ||
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe | ||
|
|
9d6b69cc49 | ||
|
|
b2cbb23fe2 | ||
|
|
9fe9fe4628 | ||
|
|
e6a254b734 | ||
|
|
dc783778c8 | ||
|
|
f8b434dd1d | ||
|
|
83741d7203 | ||
|
|
b618b7dd27 | ||
|
|
f37ff681ea | ||
|
|
8dde63a2ab | ||
|
|
1fe2ae6575 | ||
|
|
5df23b8714 | ||
|
|
46a000aa68 | ||
|
|
b46ba9dd26 | ||
|
|
9d9aef119d | ||
|
|
950a899b3c | ||
|
|
328711cade | ||
|
|
cd37ac5d6d | ||
|
|
b0504eb736 | ||
|
|
6075a177f5 | ||
|
|
6f0ac2bf4a | ||
|
|
7a3287f231 | ||
|
|
f443f625a8 | ||
|
|
655b7b790e | ||
|
|
71e0e8d0ce | ||
|
|
582a7ca888 | ||
|
|
d8a5d95db4 | ||
|
|
51002fece8 | ||
|
|
2319b4e47b | ||
|
|
6635cff46e | ||
|
|
f6b2cf3ab4 | ||
|
|
05e7293af2 | ||
|
|
b1527f5fc1 | ||
|
|
7eeb43774a | ||
|
|
b36d571b21 | ||
|
|
e4601e3554 | ||
|
|
86eeae8296 | ||
|
|
4bb732d9b9 |
@@ -36,10 +36,16 @@ optional.
|
|||||||
|
|
||||||
## Where the work is
|
## Where the work is
|
||||||
|
|
||||||
Linear project **Maestro** under team **Naps62**.
|
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
|
||||||
|
into milestones.
|
||||||
|
|
||||||
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
|
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
|
||||||
"available now" (status backlog/pending, not blocked).
|
`/work` to that tracker — they pick from open, unassigned issues whose
|
||||||
|
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
|
||||||
|
|
||||||
|
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
|
||||||
|
`difficulty` drives model selection downstream, so it tracks the shape of the
|
||||||
|
work, not its size.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -72,11 +78,20 @@ before changing anything that creates or removes one.
|
|||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
Read it before changing sidebar rendering.
|
Read it before changing sidebar rendering.
|
||||||
|
|
||||||
|
`docs/PREVIEW.md` is the contract for what the content pane shows while the
|
||||||
|
operator moves the sidebar highlight — what a preview may touch, what ends it,
|
||||||
|
what commits it. Read it before changing sidebar-driven navigation.
|
||||||
|
|
||||||
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
||||||
or is upgraded under them — the build stamp on `/api/info`, the status
|
or is upgraded under them — the build stamp on `/api/info`, the status
|
||||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||||
before changing reconnect behaviour on either client.
|
before changing reconnect behaviour on either client.
|
||||||
|
|
||||||
|
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
|
||||||
|
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
|
||||||
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
|
SessionStatus::Dormant => "dormant",
|
||||||
};
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
@@ -251,6 +252,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
|||||||
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
||||||
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||||
|
|
||||||
|
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
|
||||||
|
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
|
||||||
|
/// both produce.
|
||||||
|
pub const DEFAULT_LEADER: &str = "ctrl-a";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
|||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TuiSection {
|
||||||
|
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
|
||||||
|
/// history, but read by every client: the daemon serves it on
|
||||||
|
/// `GET /api/info` so the web follows the same knob (#159).
|
||||||
|
pub fn leader(&self) -> &str {
|
||||||
|
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TuiTheme {
|
pub enum TuiTheme {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
/// How long a session's pane must be continuously focused (a client's pane,
|
/// How long a session's pane must be continuously focused (a client's pane,
|
||||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||||
/// operator has looked, so the rail stops asking for attention.
|
/// operator has looked, so the rail stops asking for attention.
|
||||||
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(5);
|
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
|
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
|
||||||
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
||||||
@@ -161,7 +161,7 @@ mod tests {
|
|||||||
fn awaiting_input_glows_until_focus_hold_is_reached() {
|
fn awaiting_input_glows_until_focus_hold_is_reached() {
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
let mut suppressed = false;
|
let mut suppressed = false;
|
||||||
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_secs(1));
|
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_millis(200));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
apply_focus_suppression(
|
apply_focus_suppression(
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
|||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|
||||||
|
// This client's claim on the shared window size. Registered up front and
|
||||||
|
// released on every exit below, so a socket that dies mid-session stops
|
||||||
|
// constraining the size for the clients still attached.
|
||||||
|
let viewport = manager.attach_viewport();
|
||||||
|
|
||||||
// NAP-229: send buffered scrollback up-front so the client sees the last
|
// NAP-229: send buffered scrollback up-front so the client sees the last
|
||||||
// screenful immediately instead of a blank terminal. The replay lock the
|
// screenful immediately instead of a blank terminal. The replay lock the
|
||||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||||
// here and on the live receiver, and none is dropped at the seam.
|
// here and on the live receiver, and none is dropped at the seam.
|
||||||
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
|||||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||||
match ctrl {
|
match ctrl {
|
||||||
ClientControl::Resize { cols, rows } => {
|
ClientControl::Resize { cols, rows } => {
|
||||||
let _ = manager.resize(id, cols, rows).await;
|
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||||
}
|
}
|
||||||
ClientControl::Ping => {
|
ClientControl::Ping => {
|
||||||
let pong = serde_json::to_string(&ClientControl::Pong)
|
let pong = serde_json::to_string(&ClientControl::Pong)
|
||||||
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
|
|
||||||
if session_ended {
|
if session_ended {
|
||||||
let _ = sink.send(Message::Close(None)).await;
|
let _ = sink.send(Message::Close(None)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
|
config.leader.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
|||||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||||
/// `true`.
|
/// `true`.
|
||||||
pub hooks_auto_install: bool,
|
pub hooks_auto_install: bool,
|
||||||
|
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
|
||||||
|
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
|
||||||
|
/// never binds it itself; it serves it so the web client and the TUI read
|
||||||
|
/// one knob.
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
project_roots,
|
project_roots,
|
||||||
project_scan_depth,
|
project_scan_depth,
|
||||||
hooks_auto_install: file.hooks.auto_install(),
|
hooks_auto_install: file.hooks.auto_install(),
|
||||||
|
leader: file.tui.leader().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// #159: the leader the operator set for the TUI is what the daemon
|
||||||
|
/// advertises, so rebinding it once moves both clients' chord.
|
||||||
|
#[test]
|
||||||
|
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
std::fs::write(&path, "").unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-a");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[tui]
|
||||||
|
leader = "ctrl-b"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod spawn_history;
|
|||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
pub mod static_bundle;
|
pub mod static_bundle;
|
||||||
pub mod tmux;
|
pub mod tmux;
|
||||||
|
pub mod viewport;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
pub mod worktree_probe;
|
pub mod worktree_probe;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ pub struct LocalApiState {
|
|||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
|
||||||
|
/// (#159), so the web client binds the chord the operator configured
|
||||||
|
/// rather than its own default.
|
||||||
|
pub leader: Arc<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
|||||||
presets: (*s.presets).clone(),
|
presets: (*s.presets).clone(),
|
||||||
build: crate::release::build_info(),
|
build: crate::release::build_info(),
|
||||||
instance: crate::release::instance_id().to_string(),
|
instance: crate::release::instance_id().to_string(),
|
||||||
|
leader: (*s.leader).clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +307,11 @@ async fn report_activity(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<ReportActivityRequest>,
|
Json(body): Json<ReportActivityRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.report_activity(id, body.state, body.detail).await {
|
match s
|
||||||
|
.manager
|
||||||
|
.report_activity(id, body.state, body.detail, body.session_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
@@ -374,6 +383,7 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
leader: impl Into<String>,
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
@@ -381,6 +391,7 @@ pub fn state_for(
|
|||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
projects,
|
||||||
|
leader: Arc::new(leader.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
|
|||||||
use crate::sidecar::{SidecarError, SidecarStore};
|
use crate::sidecar::{SidecarError, SidecarStore};
|
||||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||||
use crate::tmux::TmuxError;
|
use crate::tmux::TmuxError;
|
||||||
|
use crate::viewport::{ViewportId, Viewports};
|
||||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||||
|
|
||||||
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
||||||
@@ -60,7 +62,10 @@ pub struct SessionRecord {
|
|||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
pub bridge: PtyBridge,
|
/// The PTY bridge feeding this session's attach stream. `None` for a
|
||||||
|
/// `Dormant` session (#162): there is no pane behind it, so there is
|
||||||
|
/// nothing to pipe and nothing to attach to.
|
||||||
|
pub bridge: Option<PtyBridge>,
|
||||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
/// outranks the activity pass's own inference; when it lapses, inference
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||||
@@ -166,6 +171,14 @@ struct Inner {
|
|||||||
report_ttl: Duration,
|
report_ttl: Duration,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
|
/// Sizes reported by the clients attached to each session, and what tmux
|
||||||
|
/// was last told. See [`crate::viewport`] for why the window is not simply
|
||||||
|
/// set to whatever arrived most recently.
|
||||||
|
viewports: Mutex<Viewports>,
|
||||||
|
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
|
||||||
|
/// client that reconnects never collides with the registration its
|
||||||
|
/// previous socket has not finished releasing.
|
||||||
|
next_viewport: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -206,6 +219,8 @@ impl SessionManager {
|
|||||||
report_ttl: crate::activity::REPORT_TTL,
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
|
viewports: Mutex::new(Viewports::new()),
|
||||||
|
next_viewport: AtomicU64::new(0),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,28 +294,48 @@ impl SessionManager {
|
|||||||
/// unknown to this daemon.
|
/// unknown to this daemon.
|
||||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
sessions
|
||||||
|
.get(&id)
|
||||||
|
.and_then(|r| r.bridge.as_ref())
|
||||||
|
.map(|b| b.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 sessions = self.inner.sessions.lock().await;
|
let (result, updated) = {
|
||||||
let rec = sessions.get(&id)?;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
// For a live session, tmux's own screen beats the byte tail: the tail
|
let rec = sessions.get_mut(&id)?;
|
||||||
// starts mid-stream, so a full-screen program (an agent TUI) replayed
|
let SessionRecord {
|
||||||
// from it renders as garbage until something forces a repaint. A dead
|
session, bridge, ..
|
||||||
// session has no pane left to capture, so it keeps the byte tail.
|
} = &mut *rec;
|
||||||
let alive = rec.session.status == SessionStatus::Running;
|
// #162: a dormant session has no bridge and no pane — nothing was
|
||||||
// The capture shells out to tmux while the replay lock is held, which
|
// ever piped for it, so there is no stream to hand back and the
|
||||||
// parks the reader for the length of one tmux round-trip. That only
|
// attach reads as unknown. Checked before anything is stamped.
|
||||||
// happens on attach, and the alternative is a seam in the stream.
|
let bridge = bridge.as_ref()?;
|
||||||
Some(rec.bridge.subscribe_with_snapshot(|| {
|
session.last_focused_at = Some(Utc::now());
|
||||||
|
let updated = session.clone();
|
||||||
|
// For a live session, tmux's own screen beats the byte tail: the
|
||||||
|
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
||||||
|
// replayed from it renders as garbage until something forces a
|
||||||
|
// repaint. A dead session has no pane left to capture, so it
|
||||||
|
// keeps the byte tail.
|
||||||
|
let alive = session.status == SessionStatus::Running;
|
||||||
|
// The capture shells out to tmux while the replay lock is held,
|
||||||
|
// which parks the reader for the length of one tmux round-trip.
|
||||||
|
// That only happens on attach, and the alternative is a seam in
|
||||||
|
// the stream.
|
||||||
|
let result = bridge.subscribe_with_snapshot(|| {
|
||||||
if !alive {
|
if !alive {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -311,7 +346,14 @@ impl SessionManager {
|
|||||||
None
|
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 {
|
||||||
@@ -448,6 +490,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -469,7 +513,7 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
bridge,
|
bridge: Some(bridge),
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -652,6 +696,8 @@ impl SessionManager {
|
|||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
@@ -676,7 +722,7 @@ impl SessionManager {
|
|||||||
// directory blocks that branch forever (#54 review).
|
// directory blocks that branch forever (#54 review).
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
bridge,
|
bridge: Some(bridge),
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -691,16 +737,105 @@ impl SessionManager {
|
|||||||
.send(CatalogEvent::SessionAdded { session });
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
}
|
}
|
||||||
|
self.register_dormant().await;
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward keystrokes to a session's pane. A dead session is read-only —
|
/// Second recovery pass (#162): register every sidecar tmux had nothing to
|
||||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
/// adopt as a [`SessionStatus::Dormant`] session.
|
||||||
/// client would have to interpret.
|
///
|
||||||
|
/// A host reboot kills the tmux server, so the first pass runs over an
|
||||||
|
/// empty list and every session would otherwise disappear from the API
|
||||||
|
/// while its sidecar sat untouched on disk. A dormant record keeps the id,
|
||||||
|
/// name, cwd, command, worktree and `created_at` — everything the sidecar
|
||||||
|
/// holds — so the operator still has a record of what was running. It has
|
||||||
|
/// no tmux session and no [`PtyBridge`] behind it: it cannot be attached
|
||||||
|
/// to, cannot be typed into, and is skipped by both watcher passes. The
|
||||||
|
/// only thing left to do with one is dismiss it, which removes the sidecar
|
||||||
|
/// so the next recovery does not bring it back.
|
||||||
|
///
|
||||||
|
/// Activity is not carried over: whatever the session was doing, it is not
|
||||||
|
/// doing it now. The rest of the sidecar is left as written.
|
||||||
|
///
|
||||||
|
/// #163: a sidecar whose `cwd` no longer exists is pruned instead —
|
||||||
|
/// nothing can be resumed in a directory that is gone, so the honest move
|
||||||
|
/// is to delete the sidecar rather than register a record no operator can
|
||||||
|
/// act on. A sidecar whose `cwd` still exists is kept regardless of age.
|
||||||
|
async fn register_dormant(&self) {
|
||||||
|
let stored = match self.inner.sidecar.load_all() {
|
||||||
|
Ok(stored) => stored,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut registered = Vec::new();
|
||||||
|
let mut pruned = 0usize;
|
||||||
|
{
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
for mut session in stored {
|
||||||
|
if sessions.contains_key(&session.id) {
|
||||||
|
// Adopted live by the first pass; its sidecar is on disk too.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !session.cwd.exists() {
|
||||||
|
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
||||||
|
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd");
|
||||||
|
}
|
||||||
|
pruned += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = session.id;
|
||||||
|
session.status = SessionStatus::Dormant;
|
||||||
|
session.foreground = None;
|
||||||
|
session.activity = ActivityState::Idle;
|
||||||
|
session.activity_detail = None;
|
||||||
|
sessions.insert(
|
||||||
|
id,
|
||||||
|
SessionRecord {
|
||||||
|
session: session.clone(),
|
||||||
|
fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")),
|
||||||
|
// Still known, so a dismissal cleans the worktree up —
|
||||||
|
// the same reason recovery restores it for a live
|
||||||
|
// session (#54 review).
|
||||||
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
|
bridge: None,
|
||||||
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
|
#[cfg(feature = "haiku-autoname")]
|
||||||
|
naming: false,
|
||||||
|
focused_since: None,
|
||||||
|
awaiting_suppressed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registered.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !registered.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
dormant = registered.len(),
|
||||||
|
"registered sessions with no live tmux as dormant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if pruned > 0 {
|
||||||
|
tracing::info!(pruned, "pruned sidecars whose cwd no longer exists");
|
||||||
|
}
|
||||||
|
for session in registered {
|
||||||
|
let _ = self
|
||||||
|
.inner
|
||||||
|
.events
|
||||||
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward keystrokes to a session's pane. A dead or dormant session is
|
||||||
|
/// read-only — its pane is gone, so this is a no-op rather than a tmux
|
||||||
|
/// failure the client would have to interpret.
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -713,13 +848,15 @@ impl SessionManager {
|
|||||||
/// Type `text` into a session's pane, with an Enter after it when
|
/// Type `text` into a session's pane, with an Enter after it when
|
||||||
/// `submit` is set.
|
/// `submit` is set.
|
||||||
///
|
///
|
||||||
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
|
||||||
/// asked for text to land somewhere, and a silent success would let a
|
/// caller asked for text to land somewhere, and a silent success would let
|
||||||
/// script believe an agent had been told something it never heard.
|
/// a script believe an agent had been told something it never heard.
|
||||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => {
|
||||||
|
return Err(SessionError::NotRunning(id))
|
||||||
|
}
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -737,12 +874,12 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead or dormant session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -751,6 +888,52 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a client that is about to start reporting its display size,
|
||||||
|
/// and hand back the handle it reports under.
|
||||||
|
///
|
||||||
|
/// Cheap and infallible: the session it will report against is not
|
||||||
|
/// consulted until the first [`Self::set_viewport`].
|
||||||
|
pub fn attach_viewport(&self) -> ViewportId {
|
||||||
|
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client reported the size it can display.
|
||||||
|
///
|
||||||
|
/// The shared tmux window is resized only when the size arbitrated across
|
||||||
|
/// every attached client actually changes — see [`crate::viewport`]. Two
|
||||||
|
/// clients of different sizes used to resize the window past each other on
|
||||||
|
/// every report, which leaves a full-screen agent repainting into rows it
|
||||||
|
/// never clears and looking, to the operator, like a pane that has stopped
|
||||||
|
/// accepting input.
|
||||||
|
pub async fn set_viewport(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
viewport: ViewportId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), SessionError> {
|
||||||
|
let target = self
|
||||||
|
.inner
|
||||||
|
.viewports
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set(id, viewport, cols, rows);
|
||||||
|
match target {
|
||||||
|
Some((cols, rows)) => self.resize(id, cols, rows).await,
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a detached client's claim on the window size, resizing to what the
|
||||||
|
/// clients still attached can display. Errors are the caller's to ignore:
|
||||||
|
/// a socket closing because its session died has nothing left to resize.
|
||||||
|
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
|
||||||
|
let target = self.inner.viewports.lock().await.release(id, viewport);
|
||||||
|
if let Some((cols, rows)) = target {
|
||||||
|
let _ = self.resize(id, cols, rows).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
||||||
/// requested name is blank so the session falls back to its command.
|
/// requested name is blank so the session falls back to its command.
|
||||||
///
|
///
|
||||||
@@ -804,11 +987,18 @@ impl SessionManager {
|
|||||||
/// A report that changes the effective state restarts `activity_since`
|
/// A report that changes the effective state restarts `activity_since`
|
||||||
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||||
/// and stays silent on the event stream at steady state.
|
/// and stays silent on the event stream at steady state.
|
||||||
|
///
|
||||||
|
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
|
||||||
|
/// by the caller from the hook payload's `session_id` field. It is
|
||||||
|
/// written once — the first report that carries one wins — and the
|
||||||
|
/// sidecar is only rewritten on that transition, never on every report,
|
||||||
|
/// since an agent's session id never changes mid-session.
|
||||||
pub async fn report_activity(
|
pub async fn report_activity(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
state: ActivityState,
|
state: ActivityState,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
|
agent_session_id: Option<String>,
|
||||||
) -> Result<(), SessionError> {
|
) -> Result<(), SessionError> {
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
@@ -824,7 +1014,16 @@ impl SessionManager {
|
|||||||
state,
|
state,
|
||||||
self.inner.report_ttl,
|
self.inner.report_ttl,
|
||||||
));
|
));
|
||||||
changed.then(|| rec.session.clone())
|
let newly_identified =
|
||||||
|
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
|
||||||
|
if newly_identified {
|
||||||
|
rec.session.agent_session_id = agent_session_id;
|
||||||
|
self.inner
|
||||||
|
.sidecar
|
||||||
|
.write(&rec.session)
|
||||||
|
.map_err(SessionError::Sidecar)?;
|
||||||
|
}
|
||||||
|
(changed || newly_identified).then(|| rec.session.clone())
|
||||||
};
|
};
|
||||||
if let Some(session) = updated {
|
if let Some(session) = updated {
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -838,11 +1037,26 @@ 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`).
|
||||||
/// An unknown session is [`SessionError::NotFound`].
|
/// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing
|
||||||
|
/// 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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1082,7 +1296,11 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
rec.session.activity_detail = None;
|
rec.session.activity_detail = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let idle = rec.bridge.idle_for();
|
// Only `Running` sessions reach this pass, and every one of them
|
||||||
|
// has a bridge; a dormant session has neither pane nor bridge.
|
||||||
|
let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// #105: while a report is in force it supplies the state and
|
// #105: while a report is in force it supplies the state and
|
||||||
// nothing here may overwrite it; only an unreported session is
|
// nothing here may overwrite it; only an unreported session is
|
||||||
// decided by inference.
|
// decided by inference.
|
||||||
@@ -1200,8 +1418,10 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// alone and stays attached to the dead record for a later forced dismissal
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// (#81), which leaves it on disk too.
|
/// (#81), which leaves it on disk too.
|
||||||
///
|
///
|
||||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
/// A dead session does not come back as dead across a daemon restart: its
|
||||||
/// adopts only what tmux still holds.
|
/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
|
||||||
|
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
|
||||||
|
/// has a pane behind it.
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
let flipped = {
|
let flipped = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -1210,7 +1430,9 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
record.session.status = SessionStatus::Dead;
|
record.session.status = SessionStatus::Dead;
|
||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
if let Some(bridge) = record.bridge.as_mut() {
|
||||||
|
bridge.seal();
|
||||||
|
}
|
||||||
let session = record.session.clone();
|
let session = record.session.clone();
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! What size the shared tmux window should be, given every client watching it.
|
||||||
|
//!
|
||||||
|
//! A tmux window has one size, but a session can have several clients attached
|
||||||
|
//! at once — two browser panes, a browser and the TUI. Each reports the size
|
||||||
|
//! *it* can display, and applying whichever arrived last makes the window
|
||||||
|
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
|
||||||
|
//! repaint at a new height, and the rows it drew at the old height are never
|
||||||
|
//! cleared: the operator ends up looking at a stale input box, types into a
|
||||||
|
//! box that is not where the screen says it is, and concludes the pane is
|
||||||
|
//! stuck.
|
||||||
|
//!
|
||||||
|
//! So the window is sized to the smallest attached client on each axis — the
|
||||||
|
//! same rule tmux uses for its own multiple clients, and the only size every
|
||||||
|
//! client can render without truncation. The size last handed to tmux is
|
||||||
|
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// One attached client within a session. Allocated by
|
||||||
|
/// [`crate::session::SessionManager`] and opaque to everything else.
|
||||||
|
pub type ViewportId = u64;
|
||||||
|
|
||||||
|
/// Per-session client sizes, and what tmux was last told.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Viewports {
|
||||||
|
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
|
||||||
|
applied: HashMap<Uuid, (u16, u16)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewports {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record what `viewport` can display. Returns the size tmux must be set
|
||||||
|
/// to, or `None` when the window already has it.
|
||||||
|
pub fn set(
|
||||||
|
&mut self,
|
||||||
|
session: Uuid,
|
||||||
|
viewport: ViewportId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Option<(u16, u16)> {
|
||||||
|
self.clients
|
||||||
|
.entry(session)
|
||||||
|
.or_default()
|
||||||
|
.insert(viewport, (cols, rows));
|
||||||
|
self.settle(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a client that has detached. Returns the size tmux must be set to
|
||||||
|
/// now that it no longer constrains the window.
|
||||||
|
///
|
||||||
|
/// The last client leaving settles nothing: the window keeps the size it
|
||||||
|
/// has rather than snapping to some remembered default no one is watching.
|
||||||
|
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
|
||||||
|
let clients = self.clients.get_mut(&session)?;
|
||||||
|
clients.remove(&viewport);
|
||||||
|
if clients.is_empty() {
|
||||||
|
self.clients.remove(&session);
|
||||||
|
self.applied.remove(&session);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.settle(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest size every attached client can render, but only when it
|
||||||
|
/// differs from what tmux was last told.
|
||||||
|
///
|
||||||
|
/// A zero on either axis is a client that cannot display anything yet — a
|
||||||
|
/// pane still laying out, a hidden tab — and must not drag the shared
|
||||||
|
/// window down with it, so it is held but left out of the arbitration.
|
||||||
|
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
|
||||||
|
let clients = self.clients.get(&session)?;
|
||||||
|
let mut cols = u16::MAX;
|
||||||
|
let mut rows = u16::MAX;
|
||||||
|
for &(c, r) in clients.values() {
|
||||||
|
if c == 0 || r == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cols = cols.min(c);
|
||||||
|
rows = rows.min(r);
|
||||||
|
}
|
||||||
|
if cols == u16::MAX || rows == u16::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = (cols, rows);
|
||||||
|
if self.applied.get(&session) == Some(&target) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.applied.insert(session, target);
|
||||||
|
Some(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_report_settles_the_window() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_that_changes_nothing_costs_no_resize() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this module exists for: two clients of different sizes used to
|
||||||
|
/// resize the window past each other on every report.
|
||||||
|
#[test]
|
||||||
|
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
|
||||||
|
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
|
||||||
|
// Whatever order they re-report in, the answer stays the smaller one.
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||||
|
assert_eq!(v.set(s, 2, 53, 20), None);
|
||||||
|
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_axis_is_arbitrated_on_its_own() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 200, 20);
|
||||||
|
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_sized_client_does_not_shrink_the_window() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||||
|
assert_eq!(v.set(s, 2, 0, 0), None);
|
||||||
|
assert_eq!(v.set(s, 2, 0, 30), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_the_small_client_gives_the_window_back() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 46);
|
||||||
|
v.set(s, 2, 80, 20);
|
||||||
|
assert_eq!(v.release(s, 2), Some((80, 46)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_the_last_client_settles_nothing() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.release(s, 1), None);
|
||||||
|
// ...and the session is forgotten, so a fresh attach settles again.
|
||||||
|
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_an_unknown_viewport_is_harmless() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let s = Uuid::new_v4();
|
||||||
|
assert_eq!(v.release(s, 7), None);
|
||||||
|
v.set(s, 1, 80, 24);
|
||||||
|
assert_eq!(v.release(s, 7), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sessions_do_not_arbitrate_against_each_other() {
|
||||||
|
let mut v = Viewports::new();
|
||||||
|
let a = Uuid::new_v4();
|
||||||
|
let b = Uuid::new_v4();
|
||||||
|
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
|
||||||
|
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
|
||||||
|
assert_eq!(v.set(a, 1, 80, 24), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
_tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
@@ -25,15 +26,17 @@ struct Fixture {
|
|||||||
async fn fixture(ttl: Duration) -> Fixture {
|
async fn fixture(ttl: Duration) -> Fixture {
|
||||||
let tmux = TmuxFixture::new();
|
let tmux = TmuxFixture::new();
|
||||||
let state_dir = TempDir::new().unwrap();
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
let manager = SessionManager::new(
|
let manager = SessionManager::new(
|
||||||
Arc::new(tmux.tmux.clone()),
|
Arc::new(tmux.tmux.clone()),
|
||||||
SidecarStore::new(state_dir.path().join("sessions")),
|
sidecar.clone(),
|
||||||
WorktreeManager::new(None),
|
WorktreeManager::new(None),
|
||||||
state_dir.path().join("fifos"),
|
state_dir.path().join("fifos"),
|
||||||
)
|
)
|
||||||
.with_activity_report_ttl(ttl);
|
.with_activity_report_ttl(ttl);
|
||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
|
sidecar,
|
||||||
_tmux: tmux,
|
_tmux: tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@ async fn report_on_unknown_session_is_not_found() {
|
|||||||
let fx = fixture(Duration::from_secs(300)).await;
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
let err = fx
|
let err = fx
|
||||||
.manager
|
.manager
|
||||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||||
@@ -83,6 +86,7 @@ async fn report_sets_error_state_and_detail() {
|
|||||||
id,
|
id,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
Some("cargo build failed: link error".into()),
|
Some("cargo build failed: link error".into()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -101,7 +105,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
|||||||
let id = spawn_sleeping(&fx.manager).await;
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -109,6 +113,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
|||||||
id,
|
id,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
Some("needs approval".into()),
|
Some("needs approval".into()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -125,7 +130,7 @@ async fn detail_is_capped_server_side() {
|
|||||||
|
|
||||||
let long = "x".repeat(10_000);
|
let long = "x".repeat(10_000);
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some(long))
|
.report_activity(id, ActivityState::Error, Some(long), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -144,7 +149,12 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
|||||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
.report_activity(
|
||||||
|
id,
|
||||||
|
ActivityState::Error,
|
||||||
|
Some("agent died mid-run".into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -162,3 +172,55 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #161: a session id does not change mid-session, so once set it is never
|
||||||
|
/// overwritten by a later report — even one that names a different id.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-second".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stored = fx
|
||||||
|
.sidecar
|
||||||
|
.read(id)
|
||||||
|
.expect("sidecar read")
|
||||||
|
.expect("sidecar present");
|
||||||
|
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
|
|||||||
async fn spawn_with(
|
async fn spawn_with(
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
) -> Harness {
|
||||||
|
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_with_leader(
|
||||||
|
hostname: &str,
|
||||||
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
leader: &str,
|
||||||
) -> Harness {
|
) -> Harness {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
@@ -48,6 +56,7 @@ async fn spawn_with(
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
projects,
|
||||||
|
leader,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
|
|||||||
assert!(resp["version"].is_string());
|
assert!(resp["version"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #159: the web client reads its leader off `/api/info` instead of a
|
||||||
|
/// hardcoded constant, so the payload has to carry the configured one — not
|
||||||
|
/// the default dressed up as one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_advertises_the_configured_leader() {
|
||||||
|
let h = spawn_with_leader(
|
||||||
|
"naps-laptop",
|
||||||
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
"ctrl-b",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_reports_the_default_leader_when_unconfigured() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-a");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn info_reports_build_and_instance() {
|
async fn info_reports_build_and_instance() {
|
||||||
// The status indicator needs a release to name and a process to compare
|
// The status indicator needs a release to name and a process to compare
|
||||||
@@ -383,6 +424,41 @@ 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;
|
||||||
@@ -450,6 +526,33 @@ async fn post_activity_reports_error_with_detail() {
|
|||||||
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_records_the_reported_session_id() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let id = spawn_via_manager(&h).await;
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"state": "Active",
|
||||||
|
"session_id": "conv-abc123"
|
||||||
|
}))
|
||||||
|
.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();
|
||||||
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_activity_to_unknown_session_returns_404() {
|
async fn post_activity_to_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
|
|||||||
Some(&"http://127.0.0.1:7070".to_string())
|
Some(&"http://127.0.0.1:7070".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two clients attached to one session used to resize the shared window past
|
||||||
|
/// each other on every report they made. Every flip repaints a full-screen
|
||||||
|
/// agent at a new height and orphans the rows it drew at the old one, which
|
||||||
|
/// reads to the operator as a pane that has stopped taking input.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
|
||||||
|
// redraw loop do. Neither report changes the arbitrated size.
|
||||||
|
for _ in 0..5 {
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
resizes,
|
||||||
|
vec![(53, 46), (53, 20)],
|
||||||
|
"the window was resized more than the arbitrated size actually changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smaller client leaving hands the window back to the one still attached.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn releasing_a_client_resizes_to_what_is_left() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
|
||||||
|
|
||||||
|
fx.manager.release_viewport(s.id, tui).await;
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,203 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
assert_eq!(snap[0].command, "sleep 30");
|
assert_eq!(snap[0].command, "sleep 30");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
|
||||||
|
/// adopt while every sidecar is still on disk. Those sessions come back as
|
||||||
|
/// `Dormant` — a record of what was running — rather than vanishing from the
|
||||||
|
/// API and leaving the operator with nothing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_registers_orphaned_sidecars_as_dormant() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
metadata.insert(
|
||||||
|
maestro_protocol::NAME_KEY.to_string(),
|
||||||
|
serde_json::Value::String("after-reboot".into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// The reboot: the tmux server is gone, the sidecar is untouched.
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
let adopted = revived.recover().await.expect("recover");
|
||||||
|
assert_eq!(adopted, 0, "no live tmux session left to adopt");
|
||||||
|
|
||||||
|
let snap = revived.snapshot().await;
|
||||||
|
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
|
||||||
|
assert_eq!(snap[0].id, s.id);
|
||||||
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||||
|
assert_eq!(snap[0].command, "sleep 30");
|
||||||
|
assert_eq!(snap[0].cwd, s.cwd);
|
||||||
|
assert_eq!(snap[0].created_at, s.created_at);
|
||||||
|
assert_eq!(
|
||||||
|
snap[0]
|
||||||
|
.metadata
|
||||||
|
.get(maestro_protocol::NAME_KEY)
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("after-reboot"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #162: a session tmux still holds is adopted `Running` by the first pass and
|
||||||
|
/// must not be re-registered as dormant by the second — the sidecar is on disk
|
||||||
|
/// for both.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_does_not_mark_adopted_sessions_dormant() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let live = 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 live");
|
||||||
|
let orphan = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 31".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create orphan");
|
||||||
|
// Only the second session loses its pane.
|
||||||
|
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||||
|
|
||||||
|
let mut snap = revived.snapshot().await;
|
||||||
|
snap.sort_by_key(|s| s.command.clone());
|
||||||
|
assert_eq!(snap.len(), 2);
|
||||||
|
assert_eq!(snap[0].id, live.id);
|
||||||
|
assert_eq!(snap[0].status, SessionStatus::Running);
|
||||||
|
assert_eq!(snap[1].id, orphan.id);
|
||||||
|
assert_eq!(snap[1].status, SessionStatus::Dormant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #163: a sidecar whose `cwd` no longer exists is pruned instead of
|
||||||
|
/// registered dormant — the worktree it pointed at is gone, so there is
|
||||||
|
/// nothing left to resume.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_deletes_sidecar_when_cwd_gone() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let cwd_path = cwd.path().to_path_buf();
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd_path.clone(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
drop(cwd);
|
||||||
|
std::fs::remove_dir_all(&cwd_path).ok();
|
||||||
|
assert!(!cwd_path.exists());
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
let adopted = revived.recover().await.expect("recover");
|
||||||
|
assert_eq!(adopted, 0);
|
||||||
|
|
||||||
|
assert!(!revived.has(s.id).await);
|
||||||
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #163: a sidecar whose `cwd` still exists is left alone regardless of age
|
||||||
|
/// — it is registered dormant like any other orphan.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_keeps_dormant_sidecar_when_cwd_exists() {
|
||||||
|
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");
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
revived.recover().await.expect("recover");
|
||||||
|
|
||||||
|
assert!(revived.has(s.id).await);
|
||||||
|
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
let snap = revived.snapshot().await;
|
||||||
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #162: dismissing a dormant session is the one thing that can be done to it
|
||||||
|
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
|
||||||
|
/// does not resurrect it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dormant_session_can_be_dismissed() {
|
||||||
|
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");
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
revived.recover().await.expect("recover");
|
||||||
|
revived.kill(s.id, false).await.expect("dismiss");
|
||||||
|
|
||||||
|
assert!(!revived.has(s.id).await);
|
||||||
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second `SessionManager` over the same dirs and the same tmux server —
|
||||||
|
/// what a daemon restart looks like from inside a test.
|
||||||
|
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
|
||||||
|
maestro_daemon::SessionManager::new(
|
||||||
|
Arc::new(fx.tmux.tmux.clone()),
|
||||||
|
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||||
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||||
|
fx.fifo_dir(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
||||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||||
@@ -1091,3 +1288,109 @@ 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,6 +31,8 @@ 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,
|
||||||
|
agent_session_id: Some("conv-abc123".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -84,15 +84,23 @@ if [ "$state" = "Prompt" ]; then
|
|||||||
>/dev/null 2>&1 || true
|
>/dev/null 2>&1 || true
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
body="{\"state\":\"$state\"}"
|
fields="\"state\":\"$state\""
|
||||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||||
# prose) would risk breaking the JSON body, and the spelling already names
|
# prose) would risk breaking the JSON body, and the spelling already names
|
||||||
# the failure.
|
# the failure.
|
||||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||||
if [ -n "$detail" ]; then
|
if [ -n "$detail" ]; then
|
||||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
fields="$fields,\"detail\":\"$detail\""
|
||||||
fi
|
fi
|
||||||
|
# Every Claude Code hook payload carries its own conversation id (#161).
|
||||||
|
# Constrained to a safe charset for the same reason as "detail" above: it is
|
||||||
|
# spliced into the JSON body unescaped.
|
||||||
|
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
||||||
|
if [ -n "$session_id" ]; then
|
||||||
|
fields="$fields,\"session_id\":\"$session_id\""
|
||||||
|
fi
|
||||||
|
body="{$fields}"
|
||||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$body" \
|
-d "$body" \
|
||||||
|
|||||||
@@ -128,6 +128,41 @@ fn script_reports_the_error_spelling_as_detail() {
|
|||||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #161: the payload's own `session_id` — the agent's conversation id — is
|
||||||
|
/// parsed out and forwarded in the activity body, so the daemon can persist
|
||||||
|
/// it on the session.
|
||||||
|
#[test]
|
||||||
|
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Active"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||||
|
assert!(
|
||||||
|
request.contains(r#""session_id":"conv-abc123""#),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A payload without a `session_id` (or one with an unexpected shape) must
|
||||||
|
/// never produce a broken body — no `session_id` field is posted.
|
||||||
|
#[test]
|
||||||
|
fn script_posts_no_session_id_when_the_payload_has_none() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["AwaitingInput"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(!request.contains("session_id"), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||||
/// one at all) must never produce a broken body — no detail is posted.
|
/// one at all) must never produce a broken body — no detail is posted.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||||
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||||
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||||
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||||
|
("claude", "claude"),
|
||||||
|
("claude-code", "claude"),
|
||||||
|
("codex", "codex"),
|
||||||
|
("opencode", "opencode"),
|
||||||
|
("gemini", "gemini"),
|
||||||
|
("aider", "aider"),
|
||||||
|
("cursor", "cursor"),
|
||||||
|
("cursor-agent", "cursor"),
|
||||||
|
("pi", "pi"),
|
||||||
|
("bash", "shell"),
|
||||||
|
("zsh", "shell"),
|
||||||
|
("sh", "shell"),
|
||||||
|
("fish", "shell"),
|
||||||
|
("dash", "shell"),
|
||||||
|
("ksh", "shell"),
|
||||||
|
("nu", "shell"),
|
||||||
|
("xonsh", "shell"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||||
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
||||||
|
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
||||||
|
/// the same tools the web's `splitCommand().tool` does.
|
||||||
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||||
|
let program = command_program(command).to_lowercase();
|
||||||
|
BY_PROGRAM
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == program)
|
||||||
|
.map(|(_, tool)| *tool)
|
||||||
|
}
|
||||||
|
|
||||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
fn is_env_assignment(word: &str) -> bool {
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
let mut chars = word.chars();
|
let mut chars = word.chars();
|
||||||
@@ -248,6 +283,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,4 +512,22 @@ mod tests {
|
|||||||
assert_eq!(command_program("sudo"), "sudo");
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
assert_eq!(command_program(""), "");
|
assert_eq!(command_program(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
||||||
|
/// tools the web's `splitCommand().tool` does.
|
||||||
|
#[test]
|
||||||
|
fn command_tool_names_the_mark_the_card_shows() {
|
||||||
|
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
||||||
|
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
||||||
|
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
||||||
|
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrow on purpose: an unrecognised program has no mark, so the
|
||||||
|
/// candidate has no `tool` field to match rather than a wrong one.
|
||||||
|
#[test]
|
||||||
|
fn command_tool_is_absent_for_an_unrecognised_program() {
|
||||||
|
assert_eq!(command_tool("hx src/main.rs"), None);
|
||||||
|
assert_eq!(command_tool(""), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ 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>>,
|
||||||
|
/// The conversation id the agent running in this session reported for
|
||||||
|
/// itself (#161), parsed from a hook's `session_id` field. Written once,
|
||||||
|
/// from the first activity report that carries one, and left alone after
|
||||||
|
/// — an agent's own session id does not change mid-session. `None` for
|
||||||
|
/// sessions that never run an agent (plain shells) or predate the field.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -115,10 +130,38 @@ fn epoch() -> DateTime<Utc> {
|
|||||||
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
/// Lifecycle of a session as the daemon knows it.
|
||||||
|
///
|
||||||
|
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
|
||||||
|
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
|
||||||
|
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
|
||||||
|
/// one this client must not treat as having a live pane to type into.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
pub enum SessionStatus {
|
pub enum SessionStatus {
|
||||||
Running,
|
Running,
|
||||||
Dead,
|
Dead,
|
||||||
|
/// Known from its sidecar, with no tmux session behind it (#162). The
|
||||||
|
/// daemon registers these at recovery time for every sidecar tmux had
|
||||||
|
/// nothing to adopt — after a host reboot, that is every session there
|
||||||
|
/// was. There is no pane and no PTY bridge: a dormant session is a record
|
||||||
|
/// of what was running, listed so the operator still has it, and the only
|
||||||
|
/// thing that can be done to it is dismissing it.
|
||||||
|
Dormant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SessionStatus {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(match s.as_str() {
|
||||||
|
"Running" => Self::Running,
|
||||||
|
"Dormant" => Self::Dormant,
|
||||||
|
// Known or not, any other spelling degrades to the inert variant.
|
||||||
|
_ => Self::Dead,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
@@ -176,6 +219,11 @@ pub struct ReportActivityRequest {
|
|||||||
/// The daemon caps its length server-side.
|
/// The daemon caps its length server-side.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub detail: Option<String>,
|
pub detail: Option<String>,
|
||||||
|
/// The agent's own conversation id (#161), forwarded from the hook
|
||||||
|
/// payload's `session_id` field. `None` for hook events that don't carry
|
||||||
|
/// one, or hooks predating this field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||||
@@ -546,6 +594,12 @@ pub struct DaemonInfo {
|
|||||||
/// network blip, even when the build did not change.
|
/// network blip, even when the build did not change.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instance: String,
|
pub instance: String,
|
||||||
|
/// Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
/// `ctrl-a`), served so the web client binds the same chord the TUI does
|
||||||
|
/// instead of its own hardcoded constant (#159). Empty from a daemon too
|
||||||
|
/// old to serve it, which clients read as "use your default".
|
||||||
|
#[serde(default)]
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||||
@@ -847,6 +901,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,6 +952,28 @@ 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
|
||||||
@@ -994,6 +1072,7 @@ mod tests {
|
|||||||
let full = ReportActivityRequest {
|
let full = ReportActivityRequest {
|
||||||
state: ActivityState::AwaitingInput,
|
state: ActivityState::AwaitingInput,
|
||||||
detail: Some("waiting on permission".into()),
|
detail: Some("waiting on permission".into()),
|
||||||
|
session_id: Some("conv-abc123".into()),
|
||||||
};
|
};
|
||||||
assert_eq!(full, round_trip(&full));
|
assert_eq!(full, round_trip(&full));
|
||||||
|
|
||||||
@@ -1022,6 +1101,33 @@ mod tests {
|
|||||||
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
||||||
serde_json::json!("Dead")
|
serde_json::json!("Dead")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(SessionStatus::Dormant).unwrap(),
|
||||||
|
serde_json::json!("Dormant")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #162: a session with no tmux behind it still rides the wire whole.
|
||||||
|
#[test]
|
||||||
|
fn dormant_session_round_trips() {
|
||||||
|
let mut s = sample_session();
|
||||||
|
s.status = SessionStatus::Dormant;
|
||||||
|
assert_eq!(s, round_trip(&s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #162: a `SessionStatus` this client does not know must degrade to the
|
||||||
|
// inert variant, not fail the whole `Session` the way a derived
|
||||||
|
// `Deserialize` would.
|
||||||
|
#[test]
|
||||||
|
fn unknown_session_status_degrades_to_dead() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
|
||||||
|
SessionStatus::Dead
|
||||||
|
);
|
||||||
|
let mut value = serde_json::to_value(sample_session()).unwrap();
|
||||||
|
value["status"] = serde_json::json!("Hibernating");
|
||||||
|
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
|
||||||
|
assert_eq!(s.status, SessionStatus::Dead);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ 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,
|
||||||
|
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
|
||||||
|
/// byte to the focused pane instead of opening the command bar, giving
|
||||||
|
/// readline's kill-to-end-of-line back.
|
||||||
|
SendLiteralCtrlK,
|
||||||
|
/// Escape hatch (#158): the leader pressed twice sends one literal
|
||||||
|
/// leader byte to the focused pane and drops the pending chord, the way
|
||||||
|
/// tmux resolves a doubled prefix key.
|
||||||
|
SendLiteralLeader,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
ShowHelp,
|
ShowHelp,
|
||||||
Quit,
|
Quit,
|
||||||
@@ -94,6 +105,9 @@ 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::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||||
|
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
Action::Quit => "Quit Maestro".into(),
|
Action::Quit => "Quit Maestro".into(),
|
||||||
@@ -120,6 +134,9 @@ 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),
|
||||||
|
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||||
|
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"Quit" => return Ok(Action::Quit),
|
||||||
@@ -239,10 +256,38 @@ 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape hatch (#156) is an ordinary action like any other.
|
||||||
|
#[test]
|
||||||
|
fn send_literal_ctrl_k_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("SendLiteralCtrlK").unwrap(),
|
||||||
|
Action::SendLiteralCtrlK
|
||||||
|
);
|
||||||
|
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
|
||||||
|
assert_eq!(
|
||||||
|
Action::SendLiteralCtrlK.help_label(),
|
||||||
|
"Send literal Ctrl-K to session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::OpenCommandBar,
|
||||||
|
Action::SendLiteralCtrlK,
|
||||||
Action::RenameHighlighted,
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::OpenSpawnModal,
|
Action::OpenSpawnModal,
|
||||||
|
|||||||
+1457
-51
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,16 @@ impl CatalogStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap O(sessions) read of current state.
|
/// Cheap O(sessions) read of current state.
|
||||||
|
/// The version counter alone, without cloning the session list. The render
|
||||||
|
/// loop reads this every tick to decide whether the sidebar is stale.
|
||||||
|
pub fn version(&self) -> u64 {
|
||||||
|
self.inner
|
||||||
|
.state
|
||||||
|
.read()
|
||||||
|
.expect("catalog rwlock poisoned")
|
||||||
|
.version
|
||||||
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> CatalogSnapshot {
|
pub fn snapshot(&self) -> CatalogSnapshot {
|
||||||
let s = self.inner.state.read().expect("catalog rwlock poisoned");
|
let s = self.inner.state.read().expect("catalog rwlock poisoned");
|
||||||
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
|
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
|
||||||
@@ -212,6 +222,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,10 +52,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// because Normal mode only exists while the sidebar is focused, so
|
// because Normal mode only exists while the sidebar is focused, so
|
||||||
// nothing forwards it to a pty.
|
// nothing forwards it to a pty.
|
||||||
("tab", "AttachHighlighted"),
|
("tab", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
// `n`/`N`, the same pair the web client binds.
|
||||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
("n", "OpenSpawnModal"),
|
||||||
|
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
|
||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("N", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("r", "RenameHighlighted"),
|
("r", "RenameHighlighted"),
|
||||||
// Focus
|
// Focus
|
||||||
@@ -64,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("pageup", "ScrollPanePageUp"),
|
("pageup", "ScrollPanePageUp"),
|
||||||
("pagedown", "ScrollPanePageDown"),
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// Chrome
|
||||||
|
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
||||||
|
// help overlay picks it up from the live keymap.
|
||||||
|
("ctrl-k", "OpenCommandBar"),
|
||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
@@ -74,10 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
|
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
||||||
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
|
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||||
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
|
/// it constantly, so binding it here would make it unreachable. The only way
|
||||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
|
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||||
|
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||||
|
/// the Insert escape with it.
|
||||||
|
///
|
||||||
|
/// 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`
|
||||||
@@ -103,7 +114,7 @@ impl TuiConfig {
|
|||||||
TuiTheme::Nord => Palette::NORD,
|
TuiTheme::Nord => Palette::NORD,
|
||||||
TuiTheme::Dracula => Palette::DRACULA,
|
TuiTheme::Dracula => Palette::DRACULA,
|
||||||
};
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader();
|
||||||
let leader = InputKey::parse(leader_str)
|
let leader = InputKey::parse(leader_str)
|
||||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||||
@@ -135,6 +146,25 @@ impl TuiConfig {
|
|||||||
keymap
|
keymap
|
||||||
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
||||||
.expect("leader canonical form is valid by construction");
|
.expect("leader canonical form is valid by construction");
|
||||||
|
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
|
||||||
|
// the same leader spec so rebinding leader rebinds this chord too.
|
||||||
|
keymap
|
||||||
|
.set(
|
||||||
|
Mode::Insert,
|
||||||
|
&format!("{} ctrl-k", leader.canonical()),
|
||||||
|
"SendLiteralCtrlK",
|
||||||
|
)
|
||||||
|
.expect("leader canonical form is valid by construction");
|
||||||
|
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
||||||
|
// pressing it twice sends the leader byte itself. Built from the same
|
||||||
|
// leader spec, so rebinding leader rebinds this chord too.
|
||||||
|
keymap
|
||||||
|
.set(
|
||||||
|
Mode::Insert,
|
||||||
|
&format!("{} {}", leader.canonical(), leader.canonical()),
|
||||||
|
"SendLiteralLeader",
|
||||||
|
)
|
||||||
|
.expect("leader canonical form is valid by construction");
|
||||||
|
|
||||||
for (input, action) in §ion.keymap_normal {
|
for (input, action) in §ion.keymap_normal {
|
||||||
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
||||||
@@ -220,18 +250,18 @@ mod tests {
|
|||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "n"),
|
||||||
Some(Action::OpenSpawnModal),
|
Some(Action::OpenSpawnModal),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||||
/// mode only exists while the sidebar is focused, so neither can leak
|
/// mode only exists while the sidebar is focused, so neither can leak
|
||||||
/// into a pty; `esc` is a second way out of Insert alongside the leader.
|
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
|
||||||
#[test]
|
#[test]
|
||||||
fn aoe_style_defaults_are_bound() {
|
fn aoe_style_defaults_are_bound() {
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
@@ -240,10 +270,7 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "tab"),
|
cfg.keymap.resolve(Mode::Normal, "tab"),
|
||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
|
||||||
cfg.keymap.resolve(Mode::Insert, "esc"),
|
|
||||||
Some(Action::LeaveInsert),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -270,6 +297,60 @@ mod tests {
|
|||||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
|
||||||
|
/// leader, `ctrl-a ctrl-k` — not the command bar.
|
||||||
|
#[test]
|
||||||
|
fn default_leader_escapes_a_literal_ctrl_k() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
|
||||||
|
Some(Action::SendLiteralCtrlK),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
|
||||||
|
/// just the Insert escape — proving the chord is built from the
|
||||||
|
/// configured leader rather than a hardcoded `ctrl-a`.
|
||||||
|
#[test]
|
||||||
|
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
|
||||||
|
let mut section = TuiSection::default();
|
||||||
|
section.leader = Some("ctrl-b".into());
|
||||||
|
let cfg = TuiConfig::load(§ion).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
|
||||||
|
Some(Action::SendLiteralCtrlK),
|
||||||
|
);
|
||||||
|
// The old leader's chord is gone along with its Insert escape.
|
||||||
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The literal-leader escape hatch (#158) is chorded off the leader
|
||||||
|
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
||||||
|
#[test]
|
||||||
|
fn default_leader_escapes_a_literal_leader() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
||||||
|
Some(Action::SendLiteralLeader),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebinding the leader must rebind the literal-leader chord with it,
|
||||||
|
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
||||||
|
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
||||||
|
#[test]
|
||||||
|
fn rebinding_leader_moves_the_literal_leader_chord() {
|
||||||
|
let mut section = TuiSection::default();
|
||||||
|
section.leader = Some("ctrl-b".into());
|
||||||
|
let cfg = TuiConfig::load(§ion).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
|
||||||
|
Some(Action::SendLiteralLeader),
|
||||||
|
);
|
||||||
|
// The old leader's chord is gone along with the rest of its bindings.
|
||||||
|
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_normal_override_replaces_default() {
|
fn user_normal_override_replaces_default() {
|
||||||
let mut section = TuiSection::default();
|
let mut section = TuiSection::default();
|
||||||
@@ -311,18 +392,18 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
/// Regression for #70: the previous canonical form of a real Shift+N
|
||||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
/// keypress was `"shift-N"`, which the default `"N"` binding never
|
||||||
/// matched. Drive the actual crossterm event, not the binding string,
|
/// matched. Drive the actual crossterm event, not the binding string,
|
||||||
/// so the dead-key bug can't come back unnoticed.
|
/// so the dead-key bug can't come back unnoticed.
|
||||||
#[test]
|
#[test]
|
||||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
|
||||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::input::{canonicalise, Region};
|
use crate::input::{canonicalise, Region};
|
||||||
|
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
|
||||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
instance: instance.into(),
|
instance: instance.into(),
|
||||||
|
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
|||||||
/// respawns there without touching the picker. Consumed by the first
|
/// respawns there without touching the picker. Consumed by the first
|
||||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||||
pub seed_cwd: Option<PathBuf>,
|
pub seed_cwd: Option<PathBuf>,
|
||||||
|
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||||
|
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||||
|
/// `None` once applied or when nothing seeded it.
|
||||||
|
pub seed_command: Option<String>,
|
||||||
/// The repository picked in step one ([`group_key`] of its main box),
|
/// The repository picked in step one ([`group_key`] of its main box),
|
||||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||||
/// cached, so a project-list refresh can't leave it stale.
|
/// cached, so a project-list refresh can't leave it stale.
|
||||||
@@ -291,6 +295,7 @@ impl Default for DraftPane {
|
|||||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
|
seed_command: None,
|
||||||
custom_command: crate::text_input::TextInput::default(),
|
custom_command: crate::text_input::TextInput::default(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
filter: crate::text_input::TextInput::default(),
|
filter: crate::text_input::TextInput::default(),
|
||||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
self.apply_seed_command();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
|
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||||
|
/// typed in.
|
||||||
|
fn apply_seed_command(&mut self) {
|
||||||
|
let Some(command) = self.seed_command.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.presets.iter().position(|p| p.command == command) {
|
||||||
|
Some(index) => self.selected_preset = index,
|
||||||
|
None => {
|
||||||
|
self.selected_preset = self
|
||||||
|
.presets
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.command.is_empty())
|
||||||
|
.unwrap_or(self.selected_preset);
|
||||||
|
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||||
@@ -1688,6 +1714,48 @@ mod tests {
|
|||||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||||
|
/// the tile that runs it when the daemon offers one.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_selects_its_own_preset() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("aider".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![
|
||||||
|
SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
},
|
||||||
|
SpawnPreset {
|
||||||
|
id: "aider".into(),
|
||||||
|
label: "Aider".into(),
|
||||||
|
command: "aider".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(draft.command(), "aider");
|
||||||
|
assert!(!draft.is_custom());
|
||||||
|
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command no preset runs falls back to the custom tile with the command
|
||||||
|
/// typed in, so the clone still spawns what it cloned.
|
||||||
|
#[test]
|
||||||
|
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
seed_command: Some("bash -l".into()),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
draft.set_presets(vec![SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
}]);
|
||||||
|
assert!(draft.is_custom());
|
||||||
|
assert_eq!(draft.command(), "bash -l");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ mod tests {
|
|||||||
tx: mpsc::channel(1).0,
|
tx: mpsc::channel(1).0,
|
||||||
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||||
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||||
};
|
};
|
||||||
emulators.insert(id, handle);
|
emulators.insert(id, handle);
|
||||||
assert!(emulators.get(id).is_some());
|
assert!(emulators.get(id).is_some());
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ pub fn resolve(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
});
|
});
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the defaults for a spawn pinned to one directory — the command
|
||||||
|
/// bar's `~` (#154), where the operator named the directory and nothing may
|
||||||
|
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
|
||||||
|
/// the project scan does not know leaves `project` `None`, which is an
|
||||||
|
/// unresolvable default, so the keystroke opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
pub fn resolve_at(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
projects: &[Project],
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
|
let project = projects.iter().find(|p| p.path == dir).cloned();
|
||||||
|
defaults(project, presets, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project's last command, or the first non-custom preset, wrapped up with
|
||||||
|
/// the rest of the defaults. Shared by both resolvers so the two entry points
|
||||||
|
/// can never settle on different commands.
|
||||||
|
fn defaults(
|
||||||
|
project: Option<Project>,
|
||||||
|
presets: &[SpawnPreset],
|
||||||
|
handle: Option<String>,
|
||||||
|
) -> FastForwardDefaults {
|
||||||
let command = project
|
let command = project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.last_command.clone())
|
.and_then(|p| p.last_command.clone())
|
||||||
@@ -159,6 +186,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command bar's `~` names the directory; nothing may override it.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
|
||||||
|
let projects = vec![
|
||||||
|
project("/repos/fresh", 0, Some("aider")),
|
||||||
|
project("/repos/pinned", 30, Some("zsh")),
|
||||||
|
];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/repos/pinned"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.project.as_ref().map(|p| p.path.clone()),
|
||||||
|
Some(PathBuf::from("/repos/pinned"))
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("zsh"));
|
||||||
|
let req = resolved.build_request().expect("every default resolved");
|
||||||
|
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the scan does not know resolves no project, so there is no
|
||||||
|
/// request to build — the caller opens the pre-filled form instead of
|
||||||
|
/// spawning somewhere else.
|
||||||
|
#[test]
|
||||||
|
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
|
||||||
|
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
|
||||||
|
let resolved = resolve_at(
|
||||||
|
std::path::Path::new("/elsewhere"),
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("otter".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.project, None);
|
||||||
|
// The preset still resolves; the missing project is what blocks it.
|
||||||
|
assert_eq!(resolved.command.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(resolved.build_request(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_every_default_from_the_most_recent_project() {
|
fn resolves_every_default_from_the_most_recent_project() {
|
||||||
let projects = vec![
|
let projects = vec![
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ pub struct ChordState {
|
|||||||
held: Vec<String>,
|
held: Vec<String>,
|
||||||
last_press: Option<Instant>,
|
last_press: Option<Instant>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
|
||||||
|
/// to be replayed as a fresh keystroke by the caller.
|
||||||
|
replay: Option<InputKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChordState {
|
impl ChordState {
|
||||||
@@ -395,6 +398,7 @@ impl ChordState {
|
|||||||
held: Vec::new(),
|
held: Vec::new(),
|
||||||
last_press: None,
|
last_press: None,
|
||||||
timeout,
|
timeout,
|
||||||
|
replay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +408,13 @@ impl ChordState {
|
|||||||
/// binding; wait for the next key.
|
/// binding; wait for the next key.
|
||||||
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
||||||
/// up in the keymap. The held buffer is cleared.
|
/// up in the keymap. The held buffer is cleared.
|
||||||
|
///
|
||||||
|
/// A held prefix that is itself directly bound (e.g. a leader key bound
|
||||||
|
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
|
||||||
|
/// to that binding the moment the next key fails to extend it, rather
|
||||||
|
/// than swallowing the held key and losing its meaning — the new key
|
||||||
|
/// never belonged to the chord and is handed back via [`Self::take_replay`]
|
||||||
|
/// so the caller can process it as its own keystroke.
|
||||||
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
|
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
|
||||||
if let Some(t) = self.last_press {
|
if let Some(t) = self.last_press {
|
||||||
if t.elapsed() > self.timeout {
|
if t.elapsed() > self.timeout {
|
||||||
@@ -428,11 +439,31 @@ impl ChordState {
|
|||||||
|
|
||||||
if is_prefix {
|
if is_prefix {
|
||||||
self.held.push(key_s);
|
self.held.push(key_s);
|
||||||
ChordResult::Pending
|
return ChordResult::Pending;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if known_bindings.contains(&candidate) {
|
||||||
self.held.clear();
|
self.held.clear();
|
||||||
|
return ChordResult::Resolved(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.held.is_empty() {
|
||||||
|
let held_str = self.held.join(" ");
|
||||||
|
self.held.clear();
|
||||||
|
if known_bindings.contains(&held_str) {
|
||||||
|
self.replay = Some(key.clone());
|
||||||
|
return ChordResult::Resolved(held_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ChordResult::Resolved(candidate)
|
ChordResult::Resolved(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
|
||||||
|
/// call, if any. The caller feeds it through again as a brand new
|
||||||
|
/// keystroke.
|
||||||
|
pub fn take_replay(&mut self) -> Option<InputKey> {
|
||||||
|
self.replay.take()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +628,8 @@ mod tests {
|
|||||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
/// make every uppercase binding (the default `N`) unreachable from the
|
||||||
|
/// keyboard (#70).
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||||
let k = InputKey::Char {
|
let k = InputKey::Char {
|
||||||
@@ -732,6 +764,41 @@ mod tests {
|
|||||||
assert_eq!(r, ChordResult::Resolved("g x".into()));
|
assert_eq!(r, ChordResult::Resolved("g x".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
|
||||||
|
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
|
||||||
|
/// doesn't complete the chord — the held key's meaning isn't lost, and
|
||||||
|
/// the new key comes back via `take_replay` for the caller to process on
|
||||||
|
/// its own (#156).
|
||||||
|
#[test]
|
||||||
|
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
|
||||||
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Pending);
|
||||||
|
|
||||||
|
let r = chord.feed(&InputKey::char('j'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
|
||||||
|
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full chord still resolves normally when the next key completes
|
||||||
|
/// it — no replay, no early fire of the shorter binding.
|
||||||
|
#[test]
|
||||||
|
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
|
||||||
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
chord.feed(&InputKey::ctrl_char('a'), &known);
|
||||||
|
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
|
||||||
|
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
|
||||||
|
assert_eq!(chord.take_replay(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chord_state_lone_key_resolves_immediately() {
|
fn chord_state_lone_key_resolves_immediately() {
|
||||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod command_bar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemon_link;
|
pub mod daemon_link;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
@@ -13,6 +14,7 @@ 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;
|
||||||
@@ -24,6 +26,7 @@ pub mod state;
|
|||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
|
|
||||||
|
use std::io::BufWriter;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -121,13 +124,23 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
let _guard = terminal::TerminalGuard::enter().map_err(|e| match e {
|
let _guard = terminal::TerminalGuard::enter().map_err(|e| match e {
|
||||||
terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()),
|
terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()),
|
||||||
})?;
|
})?;
|
||||||
let backend = CrosstermBackend::new(std::io::stdout());
|
// Buffered: ratatui issues one `write!` per cell run — thousands per
|
||||||
|
// frame — and an unbuffered `Stdout` takes the global stdout lock on every
|
||||||
|
// one of them. A single BufWriter collapses a frame into one flush.
|
||||||
|
let backend = CrosstermBackend::new(BufWriter::with_capacity(
|
||||||
|
FRAME_BUFFER_BYTES,
|
||||||
|
std::io::stdout(),
|
||||||
|
));
|
||||||
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
||||||
|
|
||||||
let app = App::new(tui_cfg, catalog, daemon, link);
|
let app = App::new(tui_cfg, catalog, daemon, link);
|
||||||
app.run(&mut terminal).await
|
app.run(&mut terminal).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One full 200x50 repaint is ~5 KB of escape sequences; 64 KB leaves room for
|
||||||
|
/// a large terminal without ever flushing mid-frame.
|
||||||
|
const FRAME_BUFFER_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
/// Startup probe budget, applied to connect *and* the whole request: the user is
|
/// Startup probe budget, applied to connect *and* the whole request: the user is
|
||||||
/// staring at a terminal, so an unreachable daemon must fail in under a second.
|
/// staring at a terminal, so an unreachable daemon must fail in under a second.
|
||||||
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
|
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
//! Pure match-and-rank function for the `ctrl-k` command bar.
|
||||||
|
//!
|
||||||
|
//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a
|
||||||
|
//! candidate is", "Match and rank", "Fixtures"). Must produce the same order
|
||||||
|
//! and scores as `web/src/matcher.ts` for the same input.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CandidateKind {
|
||||||
|
Session,
|
||||||
|
Command,
|
||||||
|
Directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum MatchFieldName {
|
||||||
|
Name,
|
||||||
|
Directory,
|
||||||
|
Command,
|
||||||
|
Tool,
|
||||||
|
Label,
|
||||||
|
Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SessionCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub directory: Option<String>,
|
||||||
|
pub command: Option<String>,
|
||||||
|
pub tool: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommandCandidate {
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DirectoryCandidate {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MatchCandidate {
|
||||||
|
Session(SessionCandidate),
|
||||||
|
Command(CommandCandidate),
|
||||||
|
Directory(DirectoryCandidate),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type MatchRange = (usize, usize);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct MatchResult<'c> {
|
||||||
|
pub candidate: &'c MatchCandidate,
|
||||||
|
pub score: i32,
|
||||||
|
pub field: Option<MatchFieldName>,
|
||||||
|
pub ranges: Vec<MatchRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Field<'c> {
|
||||||
|
name: MatchFieldName,
|
||||||
|
text: &'c str,
|
||||||
|
weight: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatchCandidate {
|
||||||
|
fn kind(&self) -> CandidateKind {
|
||||||
|
match self {
|
||||||
|
MatchCandidate::Session(_) => CandidateKind::Session,
|
||||||
|
MatchCandidate::Command(_) => CandidateKind::Command,
|
||||||
|
MatchCandidate::Directory(_) => CandidateKind::Directory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(&self) -> Vec<Field<'_>> {
|
||||||
|
match self {
|
||||||
|
MatchCandidate::Session(session) => {
|
||||||
|
let mut fields = vec![Field {
|
||||||
|
name: MatchFieldName::Name,
|
||||||
|
text: &session.name,
|
||||||
|
weight: 0,
|
||||||
|
}];
|
||||||
|
if let Some(directory) = &session.directory {
|
||||||
|
fields.push(Field {
|
||||||
|
name: MatchFieldName::Directory,
|
||||||
|
text: directory,
|
||||||
|
weight: -24,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(command) = &session.command {
|
||||||
|
fields.push(Field {
|
||||||
|
name: MatchFieldName::Command,
|
||||||
|
text: command,
|
||||||
|
weight: -32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(tool) = &session.tool {
|
||||||
|
fields.push(Field {
|
||||||
|
name: MatchFieldName::Tool,
|
||||||
|
text: tool,
|
||||||
|
weight: -40,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
MatchCandidate::Command(command) => vec![Field {
|
||||||
|
name: MatchFieldName::Label,
|
||||||
|
text: &command.label,
|
||||||
|
weight: 0,
|
||||||
|
}],
|
||||||
|
MatchCandidate::Directory(directory) => vec![Field {
|
||||||
|
name: MatchFieldName::Path,
|
||||||
|
text: &directory.path,
|
||||||
|
weight: 0,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_order(kind: CandidateKind) -> u8 {
|
||||||
|
match kind {
|
||||||
|
CandidateKind::Session => 0,
|
||||||
|
CandidateKind::Command => 1,
|
||||||
|
CandidateKind::Directory => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lower_char(c: char) -> char {
|
||||||
|
c.to_lowercase().next().unwrap_or(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lower_chars(chars: &[char]) -> Vec<char> {
|
||||||
|
chars.iter().copied().map(lower_char).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_query(query: &str) -> Vec<char> {
|
||||||
|
query
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.map(lower_char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':'];
|
||||||
|
|
||||||
|
fn boundary_bonus(text: &[char], position: usize) -> i32 {
|
||||||
|
if position == 0 {
|
||||||
|
return 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = text[position - 1];
|
||||||
|
if previous == ' ' || SEPARATORS.contains(&previous) {
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = text[position];
|
||||||
|
if current.is_uppercase() && previous.is_lowercase() {
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_digit = current.is_ascii_digit();
|
||||||
|
let previous_is_digit = previous.is_ascii_digit();
|
||||||
|
if is_digit && !previous_is_digit {
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 {
|
||||||
|
let gap = position as isize - previous_position - 1;
|
||||||
|
16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Embedding {
|
||||||
|
score: i32,
|
||||||
|
positions: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool {
|
||||||
|
let Some(incumbent) = incumbent else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if candidate.score != incumbent.score {
|
||||||
|
return candidate.score > incumbent.score;
|
||||||
|
}
|
||||||
|
for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) {
|
||||||
|
if c != i {
|
||||||
|
return c < i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dynamic program over `(query index, text position)` described in
|
||||||
|
/// "Which embedding": greedy leftmost-first matching is insufficient (e.g.
|
||||||
|
/// `mtu` against `maestro-tui`), so every reachable position at each query
|
||||||
|
/// index carries its best score and position vector forward.
|
||||||
|
fn best_embedding(query: &[char], text: &[char]) -> Option<Embedding> {
|
||||||
|
let lower_text = lower_chars(text);
|
||||||
|
let mut previous_states: HashMap<usize, Embedding> = HashMap::new();
|
||||||
|
|
||||||
|
for (query_index, &qc) in query.iter().enumerate() {
|
||||||
|
let mut states: HashMap<usize, Embedding> = HashMap::new();
|
||||||
|
|
||||||
|
for (position, &text_char) in lower_text.iter().enumerate() {
|
||||||
|
if text_char != qc {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if query_index == 0 {
|
||||||
|
states.insert(
|
||||||
|
position,
|
||||||
|
Embedding {
|
||||||
|
score: contribution(text, position, -1),
|
||||||
|
positions: vec![position],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (&previous_position, previous) in previous_states.iter() {
|
||||||
|
if previous_position >= position {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut positions = previous.positions.clone();
|
||||||
|
positions.push(position);
|
||||||
|
let candidate = Embedding {
|
||||||
|
score: previous.score
|
||||||
|
+ contribution(text, position, previous_position as isize),
|
||||||
|
positions,
|
||||||
|
};
|
||||||
|
if is_better_embedding(&candidate, states.get(&position)) {
|
||||||
|
states.insert(position, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
previous_states = states;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best: Option<Embedding> = None;
|
||||||
|
for embedding in previous_states.into_values() {
|
||||||
|
if is_better_embedding(&embedding, best.as_ref()) {
|
||||||
|
best = Some(embedding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_ranges(positions: &[usize]) -> Vec<MatchRange> {
|
||||||
|
let mut ranges: Vec<MatchRange> = Vec::new();
|
||||||
|
for &position in positions {
|
||||||
|
if let Some(last) = ranges.last_mut() {
|
||||||
|
if last.1 == position {
|
||||||
|
last.1 = position + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push((position, position + 1));
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact(text: &str) -> String {
|
||||||
|
text.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.map(lower_char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ranked<'c> {
|
||||||
|
candidate: &'c MatchCandidate,
|
||||||
|
score: i32,
|
||||||
|
field: Option<MatchFieldName>,
|
||||||
|
ranges: Vec<MatchRange>,
|
||||||
|
matched_text: String,
|
||||||
|
input_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing
|
||||||
|
/// about sessions, panes or the network — see docs/COMMAND-BAR.md.
|
||||||
|
pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec<MatchResult<'c>> {
|
||||||
|
let normalized_query = normalize_query(query);
|
||||||
|
|
||||||
|
if normalized_query.is_empty() {
|
||||||
|
return candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| MatchResult {
|
||||||
|
candidate,
|
||||||
|
score: 0,
|
||||||
|
field: None,
|
||||||
|
ranges: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<Ranked<'c>> = Vec::new();
|
||||||
|
|
||||||
|
for (input_index, candidate) in candidates.iter().enumerate() {
|
||||||
|
let mut best: Option<(MatchFieldName, &str, i32, Vec<usize>)> = None;
|
||||||
|
|
||||||
|
for field in candidate.fields() {
|
||||||
|
let text_chars: Vec<char> = field.text.chars().collect();
|
||||||
|
let Some(embedding) = best_embedding(&normalized_query, &text_chars) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let compact_text = compact(field.text);
|
||||||
|
let normalized_query_str: String = normalized_query.iter().collect();
|
||||||
|
let adjustment = if compact_text == normalized_query_str {
|
||||||
|
64
|
||||||
|
} else if compact_text.starts_with(&normalized_query_str) {
|
||||||
|
32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let score = embedding.score + adjustment + field.weight;
|
||||||
|
|
||||||
|
let is_better = match &best {
|
||||||
|
None => true,
|
||||||
|
Some((_, _, best_score, _)) => score > *best_score,
|
||||||
|
};
|
||||||
|
if is_better {
|
||||||
|
best = Some((field.name, field.text, score, embedding.positions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((field_name, field_text, score, positions)) = best {
|
||||||
|
results.push(Ranked {
|
||||||
|
candidate,
|
||||||
|
score,
|
||||||
|
field: Some(field_name),
|
||||||
|
ranges: to_ranges(&positions),
|
||||||
|
matched_text: field_text.to_string(),
|
||||||
|
input_index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.cmp(&a.score)
|
||||||
|
.then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind())))
|
||||||
|
.then_with(|| {
|
||||||
|
a.matched_text
|
||||||
|
.chars()
|
||||||
|
.count()
|
||||||
|
.cmp(&b.matched_text.chars().count())
|
||||||
|
})
|
||||||
|
.then_with(|| a.matched_text.cmp(&b.matched_text))
|
||||||
|
.then_with(|| a.input_index.cmp(&b.input_index))
|
||||||
|
});
|
||||||
|
|
||||||
|
results
|
||||||
|
.into_iter()
|
||||||
|
.map(|ranked| MatchResult {
|
||||||
|
candidate: ranked.candidate,
|
||||||
|
score: ranked.score,
|
||||||
|
field: ranked.field,
|
||||||
|
ranges: ranked.ranges,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn session(
|
||||||
|
name: &str,
|
||||||
|
directory: Option<&str>,
|
||||||
|
command: Option<&str>,
|
||||||
|
tool: Option<&str>,
|
||||||
|
) -> MatchCandidate {
|
||||||
|
MatchCandidate::Session(SessionCandidate {
|
||||||
|
name: name.to_string(),
|
||||||
|
directory: directory.map(str::to_string),
|
||||||
|
command: command.map(str::to_string),
|
||||||
|
tool: tool.map(str::to_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(label: &str) -> MatchCandidate {
|
||||||
|
MatchCandidate::Command(CommandCandidate {
|
||||||
|
label: label.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory(path: &str) -> MatchCandidate {
|
||||||
|
MatchCandidate::Directory(DirectoryCandidate {
|
||||||
|
path: path.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(candidate: &MatchCandidate) -> String {
|
||||||
|
match candidate {
|
||||||
|
MatchCandidate::Session(s) => format!("session:{}", s.name),
|
||||||
|
MatchCandidate::Command(c) => format!("command:{}", c.label),
|
||||||
|
MatchCandidate::Directory(d) => format!("directory:{}", d.path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positions(result: &MatchResult) -> Vec<usize> {
|
||||||
|
result
|
||||||
|
.ranges
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&(start, end)| start..end)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn expect_results(
|
||||||
|
query: &str,
|
||||||
|
candidates: &[MatchCandidate],
|
||||||
|
expected: &[(&str, i32, Option<MatchFieldName>, &[usize])],
|
||||||
|
) {
|
||||||
|
let results = match_and_rank(query, candidates);
|
||||||
|
|
||||||
|
let actual: Vec<(String, i32)> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| (label(r.candidate), r.score))
|
||||||
|
.collect();
|
||||||
|
let expected_labels: Vec<(String, i32)> = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(l, s, _, _)| (l.to_string(), *s))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(actual, expected_labels, "order/scores for query {query:?}");
|
||||||
|
|
||||||
|
let actual_fields: Vec<Option<MatchFieldName>> = results.iter().map(|r| r.field).collect();
|
||||||
|
let expected_fields: Vec<Option<MatchFieldName>> =
|
||||||
|
expected.iter().map(|(_, _, f, _)| *f).collect();
|
||||||
|
assert_eq!(actual_fields, expected_fields, "fields for query {query:?}");
|
||||||
|
|
||||||
|
let actual_positions: Vec<Vec<usize>> = results.iter().map(positions).collect();
|
||||||
|
let expected_positions: Vec<Vec<usize>> =
|
||||||
|
expected.iter().map(|(_, _, _, p)| p.to_vec()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
actual_positions, expected_positions,
|
||||||
|
"positions for query {query:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_empty_query_keeps_input_order() {
|
||||||
|
expect_results(
|
||||||
|
"",
|
||||||
|
&[
|
||||||
|
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||||
|
session(
|
||||||
|
"killer-app",
|
||||||
|
Some("~/dev/killer-app"),
|
||||||
|
Some("claude --resume"),
|
||||||
|
Some("claude"),
|
||||||
|
),
|
||||||
|
command("Kill selected session"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("session:kill", 0, None, &[]),
|
||||||
|
("session:killer-app", 0, None, &[]),
|
||||||
|
("command:Kill selected session", 0, None, &[]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f2_exact_session_name_beats_prefix_kind_breaks_tie() {
|
||||||
|
expect_results(
|
||||||
|
"kill",
|
||||||
|
&[
|
||||||
|
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||||
|
session(
|
||||||
|
"killer-app",
|
||||||
|
Some("~/dev/killer-app"),
|
||||||
|
Some("claude --resume"),
|
||||||
|
Some("claude"),
|
||||||
|
),
|
||||||
|
command("Kill selected session"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"session:kill",
|
||||||
|
178,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 1, 2, 3],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session:killer-app",
|
||||||
|
146,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 1, 2, 3],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"command:Kill selected session",
|
||||||
|
146,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[0, 1, 2, 3],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f3_path_segment_boundaries_beat_mid_segment() {
|
||||||
|
expect_results(
|
||||||
|
"foo",
|
||||||
|
&[
|
||||||
|
directory("~/dev/foobar"),
|
||||||
|
directory("~/src/myfoo"),
|
||||||
|
directory("~/foo"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"directory:~/foo",
|
||||||
|
78,
|
||||||
|
Some(MatchFieldName::Path),
|
||||||
|
&[2, 3, 4],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"directory:~/dev/foobar",
|
||||||
|
74,
|
||||||
|
Some(MatchFieldName::Path),
|
||||||
|
&[6, 7, 8],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"directory:~/src/myfoo",
|
||||||
|
56,
|
||||||
|
Some(MatchFieldName::Path),
|
||||||
|
&[8, 9, 10],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f4_no_match_returns_empty() {
|
||||||
|
expect_results(
|
||||||
|
"zzz",
|
||||||
|
&[
|
||||||
|
session("kill", Some("~/dev/api"), Some("bash"), None),
|
||||||
|
command("Kill selected session"),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f5_highest_scoring_embedding_beats_greedy() {
|
||||||
|
expect_results(
|
||||||
|
"mtu",
|
||||||
|
&[
|
||||||
|
session("maestro-tui", None, None, None),
|
||||||
|
session("maestro-daemon", None, None, None),
|
||||||
|
session("mtui-scratch", None, None, None),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"session:mtui-scratch",
|
||||||
|
122,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 1, 2],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session:maestro-tui",
|
||||||
|
91,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 8, 9],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f6_name_beats_weighted_directory() {
|
||||||
|
expect_results(
|
||||||
|
"api",
|
||||||
|
&[
|
||||||
|
session("api", Some("~/dev/web"), Some("bash"), None),
|
||||||
|
session("worker", Some("~/dev/api"), Some("bash"), None),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]),
|
||||||
|
(
|
||||||
|
"session:worker",
|
||||||
|
50,
|
||||||
|
Some(MatchFieldName::Directory),
|
||||||
|
&[6, 7, 8],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f7_case_insensitive_length_breaks_tie() {
|
||||||
|
expect_results(
|
||||||
|
"api",
|
||||||
|
&[
|
||||||
|
session("API-Gateway", None, None, None),
|
||||||
|
session("apiary", None, None, None),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"session:apiary",
|
||||||
|
122,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 1, 2],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session:API-Gateway",
|
||||||
|
122,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 1, 2],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f8_exact_tool_beats_command_field() {
|
||||||
|
expect_results(
|
||||||
|
"claude",
|
||||||
|
&[
|
||||||
|
session(
|
||||||
|
"nameless",
|
||||||
|
Some("~/dev/x"),
|
||||||
|
Some("claude --resume"),
|
||||||
|
Some("claude"),
|
||||||
|
),
|
||||||
|
session("clay", Some("~/dev/y"), Some("bash"), None),
|
||||||
|
],
|
||||||
|
&[(
|
||||||
|
"session:nameless",
|
||||||
|
186,
|
||||||
|
Some(MatchFieldName::Tool),
|
||||||
|
&[0, 1, 2, 3, 4, 5],
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f9_separator_earns_word_boundary_bonus() {
|
||||||
|
expect_results(
|
||||||
|
"ws",
|
||||||
|
&[
|
||||||
|
session("websearch", None, None, None),
|
||||||
|
session("web-search", None, None, None),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"session:web-search",
|
||||||
|
71,
|
||||||
|
Some(MatchFieldName::Name),
|
||||||
|
&[0, 4],
|
||||||
|
),
|
||||||
|
("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f10_command_labels_rank_by_boundary_and_gap() {
|
||||||
|
expect_results(
|
||||||
|
"se",
|
||||||
|
&[
|
||||||
|
command("Kill selected session"),
|
||||||
|
command("Create a session"),
|
||||||
|
command("Show help"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"command:Show help",
|
||||||
|
53,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[0, 6],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"command:Kill selected session",
|
||||||
|
51,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[5, 6],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"command:Create a session",
|
||||||
|
47,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[9, 10],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f11_directory_matches_separate_on_initial_gap() {
|
||||||
|
expect_results(
|
||||||
|
"dev",
|
||||||
|
&[
|
||||||
|
session("api", Some("~/dev/api"), Some("bash"), None),
|
||||||
|
session("notes", Some("~/documents/dev-notes"), Some("nvim"), None),
|
||||||
|
session("scratch", Some("~/srv/devops"), Some("bash"), None),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"session:api",
|
||||||
|
54,
|
||||||
|
Some(MatchFieldName::Directory),
|
||||||
|
&[2, 3, 4],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session:scratch",
|
||||||
|
50,
|
||||||
|
Some(MatchFieldName::Directory),
|
||||||
|
&[6, 7, 8],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session:notes",
|
||||||
|
44,
|
||||||
|
Some(MatchFieldName::Directory),
|
||||||
|
&[12, 13, 14],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f12_exact_beats_prefix() {
|
||||||
|
expect_results(
|
||||||
|
"quit",
|
||||||
|
&[command("Quit Maestro"), command("Quit")],
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"command:Quit",
|
||||||
|
178,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[0, 1, 2, 3],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"command:Quit Maestro",
|
||||||
|
146,
|
||||||
|
Some(MatchFieldName::Label),
|
||||||
|
&[0, 1, 2, 3],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -813,6 +813,21 @@ impl Palette {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Background for the card whose session the pane is showing
|
||||||
|
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
|
||||||
|
/// selection surface, so the sidebar reads as one ladder: unfilled, half
|
||||||
|
/// filled for the session in the pane, fully filled under the cursor.
|
||||||
|
///
|
||||||
|
/// Blended rather than added as a third `Surface` — a new surface means
|
||||||
|
/// eight more colours hand-picked seventeen times — which is also why it
|
||||||
|
/// borrows the *selection* surface's foregrounds: those are the ones
|
||||||
|
/// already picked for a filled row. A preset whose colours the terminal
|
||||||
|
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
|
||||||
|
/// says "in the pane" with the filled glyph alone.
|
||||||
|
pub fn pane_row_bg(&self) -> Color {
|
||||||
|
mix(self.card.bg, self.selection.bg, 0.55)
|
||||||
|
}
|
||||||
|
|
||||||
/// Border style for a panel, by whether it holds focus.
|
/// Border style for a panel, by whether it holds focus.
|
||||||
pub fn border_style(&self, focused: bool) -> Style {
|
pub fn border_style(&self, focused: bool) -> Style {
|
||||||
if focused {
|
if focused {
|
||||||
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
|
|||||||
*active_slot().read().expect("active palette lock poisoned")
|
*active_slot().read().expect("active palette lock poisoned")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
|
||||||
|
/// there is nothing to blend and `from` comes back unchanged.
|
||||||
|
fn mix(from: Color, to: Color, amount: f64) -> Color {
|
||||||
|
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
|
||||||
|
return from;
|
||||||
|
};
|
||||||
|
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
|
||||||
|
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
|
||||||
|
}
|
||||||
|
|
||||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||||
/// luminance — they are whatever the terminal theme says — so they return
|
/// luminance — they are whatever the terminal theme says — so they return
|
||||||
/// `None` and cannot be contrast-checked.
|
/// `None` and cannot be contrast-checked.
|
||||||
@@ -937,6 +962,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pane row is the middle rung: a visible fill against the card, still
|
||||||
|
/// a different surface from the cursor's, and carrying the selection
|
||||||
|
/// surface's foregrounds, which it borrows rather than shipping its own.
|
||||||
|
#[test]
|
||||||
|
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
|
||||||
|
for (preset, p) in rgb_presets() {
|
||||||
|
let bg = p.pane_row_bg();
|
||||||
|
let from_card = contrast_ratio(p.card.bg, bg)
|
||||||
|
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
|
||||||
|
assert!(
|
||||||
|
from_card >= 1.15,
|
||||||
|
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
|
||||||
|
);
|
||||||
|
let from_selection =
|
||||||
|
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
|
||||||
|
assert!(
|
||||||
|
from_selection >= 1.15,
|
||||||
|
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
|
||||||
|
);
|
||||||
|
for (role, color) in p.selection.roles() {
|
||||||
|
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
|
||||||
|
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||||
|
assert!(
|
||||||
|
ratio >= floor,
|
||||||
|
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A preset the terminal themes has nothing to blend from.
|
||||||
|
#[test]
|
||||||
|
fn ansi_keeps_the_card_background_for_the_pane_row() {
|
||||||
|
let p = Palette::ANSI;
|
||||||
|
assert_eq!(p.pane_row_bg(), p.card.bg);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_preset_supplies_every_role() {
|
fn every_preset_supplies_every_role() {
|
||||||
for (name, p) in Palette::presets() {
|
for (name, p) in Palette::presets() {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
|
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
|
||||||
|
/// up.
|
||||||
|
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
|
||||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||||
/// restart overlay (`docs/RESTART.md`).
|
/// restart overlay (`docs/RESTART.md`).
|
||||||
pub link: &'a crate::daemon_link::LinkStatus,
|
pub link: &'a crate::daemon_link::LinkStatus,
|
||||||
@@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
}
|
}
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
|
Some(OverlayKind::CommandBar) => {
|
||||||
|
if let Some(bar) = inputs.command_bar {
|
||||||
|
if let Some((x, y)) = bar.render(f, area) {
|
||||||
|
f.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -415,6 +426,7 @@ mod tests {
|
|||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let state = AppState::new();
|
let state = AppState::new();
|
||||||
@@ -427,6 +439,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||||
@@ -511,6 +524,7 @@ mod tests {
|
|||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
@@ -522,6 +536,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -561,6 +576,7 @@ mod tests {
|
|||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let draw_map = |focus| {
|
let draw_map = |focus| {
|
||||||
@@ -575,6 +591,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -601,6 +618,7 @@ mod tests {
|
|||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let render = |focus| {
|
let render = |focus| {
|
||||||
@@ -615,6 +633,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
command_bar: None,
|
||||||
link: &online_link(),
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! parser is **not** reset on reconnect — the last-known frame stays
|
//! parser is **not** reset on reconnect — the last-known frame stays
|
||||||
//! visible until new bytes arrive, so the user doesn't see a blank.
|
//! visible until new bytes arrive, so the user doesn't see a blank.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -56,11 +56,15 @@ pub enum SessionState {
|
|||||||
/// here.
|
/// here.
|
||||||
/// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without
|
/// - `state` — `Arc<AtomicU8>`: live status of the WS, queryable without
|
||||||
/// holding the screen lock.
|
/// holding the screen lock.
|
||||||
|
/// - `revision` — `Arc<AtomicU64>`: bumped every time the parser's screen is
|
||||||
|
/// mutated. The render loop compares it against the last drawn frame's so an
|
||||||
|
/// idle pane costs a `load` instead of a repaint.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SessionHandle {
|
pub struct SessionHandle {
|
||||||
pub tx: mpsc::Sender<SessionInput>,
|
pub tx: mpsc::Sender<SessionInput>,
|
||||||
pub screen: Arc<Mutex<vt100::Parser>>,
|
pub screen: Arc<Mutex<vt100::Parser>>,
|
||||||
pub(crate) state: Arc<AtomicU8>,
|
pub(crate) state: Arc<AtomicU8>,
|
||||||
|
pub(crate) revision: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for SessionHandle {
|
impl std::fmt::Debug for SessionHandle {
|
||||||
@@ -81,6 +85,12 @@ impl SessionHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Monotonic counter of screen mutations. Only equality across two reads
|
||||||
|
/// is meaningful — the loop uses it to decide whether a frame is stale.
|
||||||
|
pub fn revision(&self) -> u64 {
|
||||||
|
self.revision.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the underlying task. Used when the pane closes and the user has
|
/// Stop the underlying task. Used when the pane closes and the user has
|
||||||
/// no other tabs pointing at this session.
|
/// no other tabs pointing at this session.
|
||||||
pub fn end(&self) {
|
pub fn end(&self) {
|
||||||
@@ -109,9 +119,11 @@ impl SessionTask {
|
|||||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||||
|
let revision = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
let parser_bg = parser.clone();
|
let parser_bg = parser.clone();
|
||||||
let state_bg = state.clone();
|
let state_bg = state.clone();
|
||||||
|
let revision_bg = revision.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let url = ws_url;
|
let url = ws_url;
|
||||||
@@ -151,6 +163,7 @@ impl SessionTask {
|
|||||||
}
|
}
|
||||||
SessionInput::Resize { cols, rows } => {
|
SessionInput::Resize { cols, rows } => {
|
||||||
parser_bg.lock().expect("vt100 parser mutex").set_size(rows, cols);
|
parser_bg.lock().expect("vt100 parser mutex").set_size(rows, cols);
|
||||||
|
revision_bg.fetch_add(1, Ordering::Release);
|
||||||
let frame = serde_json::to_string(&ClientControl::Resize {
|
let frame = serde_json::to_string(&ClientControl::Resize {
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
@@ -166,6 +179,7 @@ impl SessionTask {
|
|||||||
match frame {
|
match frame {
|
||||||
Some(Ok(Message::Binary(bytes))) => {
|
Some(Ok(Message::Binary(bytes))) => {
|
||||||
parser_bg.lock().expect("vt100 parser mutex").process(&bytes);
|
parser_bg.lock().expect("vt100 parser mutex").process(&bytes);
|
||||||
|
revision_bg.fetch_add(1, Ordering::Release);
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
// Ping/Pong control frames travel as text JSON. Reply to Ping.
|
// Ping/Pong control frames travel as text JSON. Reply to Ping.
|
||||||
@@ -202,6 +216,7 @@ impl SessionTask {
|
|||||||
tx,
|
tx,
|
||||||
screen: parser,
|
screen: parser,
|
||||||
state,
|
state,
|
||||||
|
revision,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +243,7 @@ mod tests {
|
|||||||
tx: mpsc::channel(1).0,
|
tx: mpsc::channel(1).0,
|
||||||
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||||
state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)),
|
state: Arc::new(AtomicU8::new(SessionState::Connecting as u8)),
|
||||||
|
revision: Arc::new(AtomicU64::new(0)),
|
||||||
};
|
};
|
||||||
assert_eq!(h.state(), SessionState::Connecting);
|
assert_eq!(h.state(), SessionState::Connecting);
|
||||||
}
|
}
|
||||||
@@ -238,8 +254,26 @@ mod tests {
|
|||||||
tx: mpsc::channel(1).0,
|
tx: mpsc::channel(1).0,
|
||||||
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||||
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
revision: Arc::new(AtomicU64::new(0)),
|
||||||
};
|
};
|
||||||
h.end();
|
h.end();
|
||||||
assert_eq!(h.state(), SessionState::Ended);
|
assert_eq!(h.state(), SessionState::Ended);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revision_starts_at_zero_and_tracks_screen_mutations() {
|
||||||
|
let revision = Arc::new(AtomicU64::new(0));
|
||||||
|
let h = SessionHandle {
|
||||||
|
tx: mpsc::channel(1).0,
|
||||||
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||||
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
revision: revision.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(h.revision(), 0);
|
||||||
|
|
||||||
|
// Stands in for the WS pump: process bytes, then bump.
|
||||||
|
h.screen.lock().unwrap().process(b"hello");
|
||||||
|
revision.fetch_add(1, Ordering::Release);
|
||||||
|
assert_eq!(h.revision(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
|
|||||||
/// both card rows, replacing the old inline dot glyph on the meta line.
|
/// both card rows, replacing the old inline dot glyph on the meta line.
|
||||||
const RAIL: &str = "▌";
|
const RAIL: &str = "▌";
|
||||||
|
|
||||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
|
||||||
|
/// on its own row: a highlight that lands on the session already in the pane
|
||||||
|
/// still has to be visible.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum CardFill {
|
||||||
|
/// An ordinary card, on the card surface.
|
||||||
|
None,
|
||||||
|
/// The session the pane is showing.
|
||||||
|
Pane,
|
||||||
|
/// Under the cursor, with the sidebar holding the keyboard.
|
||||||
|
Cursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardFill {
|
||||||
|
/// Both filled tiers carry the selection surface's foregrounds — those are
|
||||||
|
/// the colours picked for a filled row, and a colour chosen for the card
|
||||||
|
/// background is not legible on one.
|
||||||
|
fn filled(self) -> bool {
|
||||||
|
self != CardFill::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cards take the cursor's fill only while the sidebar owns focus. An
|
||||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||||
/// keyboard via a filled selection background.
|
/// keyboard via a filled selection background.
|
||||||
fn card_base(highlighted: bool, focused: bool) -> Style {
|
fn card_base(fill: CardFill, focused: bool) -> Style {
|
||||||
let palette = palette::active();
|
let palette = palette::active();
|
||||||
let mut style = palette.surface(highlighted && focused).text_style();
|
let mut style = palette.surface(fill.filled()).text_style();
|
||||||
if focused && !highlighted {
|
match fill {
|
||||||
style = style.bg(palette.sidebar_focus_bg);
|
CardFill::Cursor => {}
|
||||||
|
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
|
||||||
|
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
|
||||||
|
CardFill::None => {}
|
||||||
}
|
}
|
||||||
style
|
style
|
||||||
}
|
}
|
||||||
@@ -55,6 +80,9 @@ pub struct Sidebar {
|
|||||||
pub sessions: Vec<Session>,
|
pub sessions: Vec<Session>,
|
||||||
pub highlight: Option<SidebarItem>,
|
pub highlight: Option<SidebarItem>,
|
||||||
pub attached: HashSet<Uuid>,
|
pub attached: HashSet<Uuid>,
|
||||||
|
/// The session the active tab's pane is showing. It wears a fill of its
|
||||||
|
/// own, so "in the pane" is not carried by the ● glyph alone.
|
||||||
|
pub pane_session: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -113,8 +141,7 @@ impl Sidebar {
|
|||||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||||
if !group.collapsed {
|
if !group.collapsed {
|
||||||
for s in &group.sessions {
|
for s in &group.sessions {
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let base = card_base(self.card_fill(s, focused), focused);
|
||||||
let base = card_base(highlighted, focused);
|
|
||||||
lines.push(Line::from(self.name_row(s, width, focused)));
|
lines.push(Line::from(self.name_row(s, width, focused)));
|
||||||
let meta = self.meta_row(s, width, focused);
|
let meta = self.meta_row(s, width, focused);
|
||||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||||
@@ -226,19 +253,34 @@ impl Sidebar {
|
|||||||
pad_to_width(spans, width, base)
|
pad_to_width(spans, width, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which fill this session's card wears. The cursor wins on its own row,
|
||||||
|
/// so a highlight parked on the session already in the pane stays visible.
|
||||||
|
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
|
||||||
|
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
|
||||||
|
CardFill::Cursor
|
||||||
|
} else if self.pane_session == Some(s.id) {
|
||||||
|
CardFill::Pane
|
||||||
|
} else {
|
||||||
|
CardFill::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
(SessionStatus::Dead, _) => "✗",
|
(SessionStatus::Dead, _) => "✗",
|
||||||
|
// #162: known from its sidecar, nothing running behind it.
|
||||||
|
(SessionStatus::Dormant, _) => "◌",
|
||||||
(SessionStatus::Running, true) => "●",
|
(SessionStatus::Running, true) => "●",
|
||||||
(SessionStatus::Running, false) => "○",
|
(SessionStatus::Running, false) => "○",
|
||||||
};
|
};
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let base = card_base(highlighted, focused);
|
let fill = self.card_fill(s, focused);
|
||||||
|
let base = card_base(fill, focused);
|
||||||
let prefix = 1 + INDENT.chars().count() + 2;
|
let prefix = 1 + INDENT.chars().count() + 2;
|
||||||
let name_budget = width.saturating_sub(prefix);
|
let name_budget = width.saturating_sub(prefix);
|
||||||
|
|
||||||
let edge = highlighted && !focused;
|
let edge = highlighted && !focused;
|
||||||
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
|
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||||
let mut spans: Vec<Span<'static>> = vec![
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
Span::styled(RAIL, rail_style),
|
Span::styled(RAIL, rail_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -257,7 +299,7 @@ impl Sidebar {
|
|||||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||||
base.patch(
|
base.patch(
|
||||||
palette::active()
|
palette::active()
|
||||||
.surface(highlighted && focused)
|
.surface(fill.filled())
|
||||||
.muted_style()
|
.muted_style()
|
||||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||||
)
|
)
|
||||||
@@ -282,12 +324,13 @@ impl Sidebar {
|
|||||||
// on line 1; the kill confirm names the specifics.
|
// on line 1; the kill confirm names the specifics.
|
||||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||||
let dead = s.status == SessionStatus::Dead;
|
let dead = s.status == SessionStatus::Dead;
|
||||||
|
let dormant = s.status == SessionStatus::Dormant;
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let base = card_base(highlighted, focused);
|
let fill = self.card_fill(s, focused);
|
||||||
let rail_style =
|
let base = card_base(fill, focused);
|
||||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||||
let muted = palette::active()
|
let muted = palette::active()
|
||||||
.surface(highlighted && focused)
|
.surface(fill.filled())
|
||||||
.muted_style()
|
.muted_style()
|
||||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||||
let edge = highlighted && !focused;
|
let edge = highlighted && !focused;
|
||||||
@@ -308,7 +351,7 @@ impl Sidebar {
|
|||||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||||
if let Some(b) = blocked {
|
if let Some(b) = blocked {
|
||||||
let palette = palette::active();
|
let palette = palette::active();
|
||||||
let surface = palette.surface(highlighted && focused);
|
let surface = palette.surface(fill.filled());
|
||||||
for (kind, text) in b.stats() {
|
for (kind, text) in b.stats() {
|
||||||
let style = match kind {
|
let style = match kind {
|
||||||
StatKind::Added => muted.fg(surface.active),
|
StatKind::Added => muted.fg(surface.active),
|
||||||
@@ -321,6 +364,9 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
|
if dormant {
|
||||||
|
tail.push(Span::styled(" dormant", muted));
|
||||||
|
}
|
||||||
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||||
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||||
|
|
||||||
@@ -352,10 +398,8 @@ impl Sidebar {
|
|||||||
/// state word is never spelled out — this colour carries it. The highlighted
|
/// state word is never spelled out — this colour carries it. The highlighted
|
||||||
/// card takes its state colours from the selection surface: a colour picked
|
/// card takes its state colours from the selection surface: a colour picked
|
||||||
/// for the dark card background is not legible on the selection background.
|
/// for the dark card background is not legible on the selection background.
|
||||||
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
|
||||||
palette::active()
|
palette::active().surface(fill.filled()).activity(state)
|
||||||
.surface(highlighted && focused)
|
|
||||||
.activity(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||||
@@ -461,6 +505,8 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +566,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&draw(&sb, 28, 12));
|
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -544,6 +591,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
})
|
})
|
||||||
.groups(&HashSet::new());
|
.groups(&HashSet::new());
|
||||||
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||||
@@ -555,6 +603,7 @@ mod tests {
|
|||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||||
assert!(sidebar.rendered_items().is_empty());
|
assert!(sidebar.rendered_items().is_empty());
|
||||||
@@ -566,6 +615,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let key = PathBuf::from("/tmp");
|
let key = PathBuf::from("/tmp");
|
||||||
let mut collapsed = HashSet::new();
|
let mut collapsed = HashSet::new();
|
||||||
@@ -590,6 +640,7 @@ mod tests {
|
|||||||
sessions: vec![session],
|
sessions: vec![session],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
||||||
let collapsed = HashSet::from([key]);
|
let collapsed = HashSet::from([key]);
|
||||||
@@ -620,6 +671,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sidebar, 40, 8));
|
let all = rows(&draw(&sidebar, 40, 8));
|
||||||
assert!(all[1].contains("/tmp"));
|
assert!(all[1].contains("/tmp"));
|
||||||
@@ -635,6 +687,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: [sid].into_iter().collect(),
|
attached: [sid].into_iter().collect(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 6));
|
let all = rows(&draw(&sb, 28, 6));
|
||||||
let line = row_containing(&all, "claude");
|
let line = row_containing(&all, "claude");
|
||||||
@@ -645,6 +698,87 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any cell in the buffer is painted on `bg`.
|
||||||
|
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
|
||||||
|
(0..buf.area.height)
|
||||||
|
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
|
||||||
|
/// not the ● glyph alone carrying "this is the one on screen".
|
||||||
|
#[test]
|
||||||
|
fn the_session_in_the_pane_wears_the_pane_fill() {
|
||||||
|
let sid = Uuid::from_u128(1);
|
||||||
|
let p = palette::active();
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: None,
|
||||||
|
attached: [sid].into_iter().collect(),
|
||||||
|
pane_session: Some(sid),
|
||||||
|
};
|
||||||
|
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
|
||||||
|
|
||||||
|
let none = Sidebar {
|
||||||
|
pane_session: None,
|
||||||
|
..sb
|
||||||
|
};
|
||||||
|
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor's fill wins on its own row: a highlight that lands on the
|
||||||
|
/// session already in the pane has to stay visible.
|
||||||
|
#[test]
|
||||||
|
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
|
||||||
|
let sid = Uuid::from_u128(1);
|
||||||
|
let p = palette::active();
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: Some(SidebarItem::Session(sid)),
|
||||||
|
attached: [sid].into_iter().collect(),
|
||||||
|
pane_session: Some(sid),
|
||||||
|
};
|
||||||
|
let buf = draw_with_focus(&sb, 28, 6, true);
|
||||||
|
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
|
||||||
|
assert!(
|
||||||
|
!any_cell_on(&buf, p.pane_row_bg()),
|
||||||
|
"the pane fill is showing through the cursor's own row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card the pane is showing is a filled row, so it takes the selection
|
||||||
|
/// surface's foregrounds — a colour picked for the card background is not
|
||||||
|
/// legible on a fill.
|
||||||
|
#[test]
|
||||||
|
fn the_pane_row_carries_selection_foregrounds() {
|
||||||
|
let sid = Uuid::from_u128(1);
|
||||||
|
let p = palette::active();
|
||||||
|
let mut sess = session_named(1, "claude");
|
||||||
|
sess.activity = ActivityState::AwaitingInput;
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![sess],
|
||||||
|
highlight: None,
|
||||||
|
attached: [sid].into_iter().collect(),
|
||||||
|
pane_session: Some(sid),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 6);
|
||||||
|
let on_pane_row = |fg: Color| {
|
||||||
|
(0..buf.area.height).any(|y| {
|
||||||
|
(0..buf.area.width).any(|x| {
|
||||||
|
let st = buf[(x, y)].style();
|
||||||
|
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
|
||||||
|
"the rail must carry the state in the filled surface's colours"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
|
||||||
|
"a card surface colour is painted onto the pane fill"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dead_session_uses_cross_glyph() {
|
fn dead_session_uses_cross_glyph() {
|
||||||
let mut sess = session_named(1, "claude");
|
let mut sess = session_named(1, "claude");
|
||||||
@@ -653,6 +787,7 @@ mod tests {
|
|||||||
sessions: vec![sess],
|
sessions: vec![sess],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 6);
|
let buf = draw(&sb, 28, 6);
|
||||||
let all = rows(&buf);
|
let all = rows(&buf);
|
||||||
@@ -681,6 +816,7 @@ mod tests {
|
|||||||
sessions: vec![blocked, clear, unprobed],
|
sessions: vec![blocked, clear, unprobed],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 44, 10));
|
let all = rows(&draw(&sb, 44, 10));
|
||||||
|
|
||||||
@@ -727,6 +863,7 @@ mod tests {
|
|||||||
sessions: vec![generated, settled],
|
sessions: vec![generated, settled],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 44, 8);
|
let buf = draw(&sb, 44, 8);
|
||||||
let palette = palette::active();
|
let palette = palette::active();
|
||||||
@@ -759,6 +896,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 44, 8);
|
let buf = draw(&sb, 44, 8);
|
||||||
let palette = palette::active();
|
let palette = palette::active();
|
||||||
@@ -785,6 +923,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
assert_eq!(content(&all[0]), "sessions");
|
assert_eq!(content(&all[0]), "sessions");
|
||||||
@@ -816,6 +955,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -855,6 +995,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let found = (0..buf.area.height)
|
let found = (0..buf.area.height)
|
||||||
@@ -883,6 +1024,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let on_selection = |fg: Color| {
|
let on_selection = |fg: Color| {
|
||||||
@@ -922,6 +1064,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -932,6 +1075,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
|
||||||
|
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
|
||||||
|
/// it never reads as something that exited.
|
||||||
|
#[test]
|
||||||
|
fn dormant_session_reads_as_known_but_not_running() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.status = SessionStatus::Dormant;
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
|
assert!(
|
||||||
|
all[idx].contains('\u{25cc}'),
|
||||||
|
"expected the dormant glyph in: {:?}",
|
||||||
|
all[idx]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all[idx + 1].contains("dormant"),
|
||||||
|
"expected dormant fragment in: {:?}",
|
||||||
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!all[idx + 1].contains("exited"),
|
||||||
|
"a dormant session never reads as exited: {:?}",
|
||||||
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_drops_the_commands_arguments() {
|
fn meta_line_drops_the_commands_arguments() {
|
||||||
@@ -941,6 +1117,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 60, 6));
|
let all = rows(&draw(&sb, 60, 6));
|
||||||
let meta = content(&all[3]);
|
let meta = content(&all[3]);
|
||||||
@@ -969,6 +1146,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -998,6 +1176,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 8));
|
let all = rows(&draw(&sb, 40, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -1025,6 +1204,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 8));
|
let all = rows(&draw(&sb, 40, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -1053,6 +1233,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 42, 8));
|
let all = rows(&draw(&sb, 42, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
@@ -1086,6 +1267,7 @@ mod tests {
|
|||||||
sessions: vec![legacy],
|
sessions: vec![legacy],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&draw(&sb, 40, 8));
|
let rendered = render_to_string(&draw(&sb, 40, 8));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1123,11 +1305,14 @@ 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,
|
||||||
|
agent_session_id: None,
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&draw(&sb, 28, 6));
|
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1143,6 +1328,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 6));
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1164,6 +1350,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 9, 6));
|
let all = rows(&draw(&sb, 9, 6));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1190,6 +1377,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 29, 6));
|
let all = rows(&draw(&sb, 29, 6));
|
||||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||||
@@ -1214,6 +1402,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 6));
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||||
@@ -1235,6 +1424,7 @@ mod tests {
|
|||||||
sessions: vec![highlighted],
|
sessions: vec![highlighted],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
for y in 0..buf.area.height {
|
for y in 0..buf.area.height {
|
||||||
@@ -1260,6 +1450,7 @@ mod tests {
|
|||||||
sessions: vec![one, two],
|
sessions: vec![one, two],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let all = rows(&buf);
|
let all = rows(&buf);
|
||||||
@@ -1295,6 +1486,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||||
let y = (0..buf.area.height)
|
let y = (0..buf.area.height)
|
||||||
@@ -1314,6 +1506,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
},
|
},
|
||||||
28,
|
28,
|
||||||
8,
|
8,
|
||||||
@@ -1334,6 +1527,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||||
@@ -1352,6 +1546,7 @@ mod tests {
|
|||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let line = content(row_containing(&all, "claude"));
|
let line = content(row_containing(&all, "claude"));
|
||||||
@@ -1369,6 +1564,7 @@ mod tests {
|
|||||||
sessions: vec![one, two],
|
sessions: vec![one, two],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let line = row_containing(&all, "bash");
|
let line = row_containing(&all, "bash");
|
||||||
@@ -1388,6 +1584,7 @@ mod tests {
|
|||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let line = row_containing(&all, "an-unreasonably");
|
let line = row_containing(&all, "an-unreasonably");
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ pub enum OverlayKind {
|
|||||||
/// so this stays comparable.
|
/// so this stays comparable.
|
||||||
Rename(Uuid),
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
|
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
|
||||||
|
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
|
||||||
|
/// rename buffer does — so this stays comparable.
|
||||||
|
CommandBar,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +67,11 @@ pub struct AppState {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub sidebar_visible: bool,
|
pub sidebar_visible: bool,
|
||||||
pub sidebar_highlight: Option<SidebarItem>,
|
pub sidebar_highlight: Option<SidebarItem>,
|
||||||
|
/// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because
|
||||||
|
/// the sidebar highlight is on it, not because a tab holds it. It never
|
||||||
|
/// enters `tabs`, is never reported to the daemon as focused, and is
|
||||||
|
/// dropped the moment the highlight moves on.
|
||||||
|
pub preview: Option<Uuid>,
|
||||||
/// Group paths whose cards are hidden. This is UI state rather than
|
/// Group paths whose cards are hidden. This is UI state rather than
|
||||||
/// catalog state, so it survives every catalog redraw.
|
/// catalog state, so it survives every catalog redraw.
|
||||||
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||||
@@ -91,6 +100,7 @@ impl AppState {
|
|||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
sidebar_visible: true,
|
sidebar_visible: true,
|
||||||
sidebar_highlight: None,
|
sidebar_highlight: None,
|
||||||
|
preview: None,
|
||||||
collapsed_sidebar_groups: HashSet::new(),
|
collapsed_sidebar_groups: HashSet::new(),
|
||||||
overlay: None,
|
overlay: None,
|
||||||
}
|
}
|
||||||
@@ -126,6 +136,9 @@ impl AppState {
|
|||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||||
|
// Committing to what was previewed: the pane owns it now, so the
|
||||||
|
// preview has nothing left to say.
|
||||||
|
self.preview = None;
|
||||||
self.active_tab_mut().pane = Some(session_id);
|
self.active_tab_mut().pane = Some(session_id);
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
@@ -150,6 +163,13 @@ impl AppState {
|
|||||||
self.active_tab().session()
|
self.active_tab().session()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the pane actually draws: the preview when one is up, otherwise the
|
||||||
|
/// active tab's session. A preview covers the pane without replacing it,
|
||||||
|
/// so ending one gives back exactly what was there (`docs/PREVIEW.md`).
|
||||||
|
pub fn displayed_session(&self) -> Option<Uuid> {
|
||||||
|
self.preview.or_else(|| self.active_session())
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight one step through the rendered item
|
/// Move the sidebar highlight one step through the rendered item
|
||||||
/// sequence, in the direction of `delta` (positive = down), landing only
|
/// sequence, in the direction of `delta` (positive = down), landing only
|
||||||
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# COMMAND-BAR.md — the command bar contract
|
||||||
|
|
||||||
|
One rule for the `ctrl-k` command bar in both clients, so the web bar and the
|
||||||
|
TUI bar open the same way, rank the same results in the same order, and act on
|
||||||
|
the same keys. Read this before changing either. Both clients implement exactly
|
||||||
|
what is written here; if the behaviour needs to change, change it here first.
|
||||||
|
|
||||||
|
The bar is one surface with three jobs: **go to a session**, **run a command**,
|
||||||
|
**spawn into a directory**. It is not a new place to configure anything and it
|
||||||
|
does not own any action — it chooses one and hands off to the flow that already
|
||||||
|
implements it.
|
||||||
|
|
||||||
|
## Opening
|
||||||
|
|
||||||
|
`ctrl-k` opens the bar from anywhere:
|
||||||
|
|
||||||
|
| Client | From | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TUI | Normal mode | Sidebar has the keyboard. |
|
||||||
|
| TUI | Insert mode | The pane has the keyboard; the bar still opens. |
|
||||||
|
| Web | Sidebar focused | Ordinary global binding. |
|
||||||
|
| Web | Terminal focused | Intercepted before xterm sees it. |
|
||||||
|
|
||||||
|
It is a **keymap entry like any other**, not a hard-wired key. In the TUI it is
|
||||||
|
bound in both `DEFAULT_NORMAL` and `DEFAULT_INSERT` and is rebindable through
|
||||||
|
`[tui.keymap.*]`; because the help overlay builds its lines from the live
|
||||||
|
keymap, it appears there without a second list to maintain. In the web it is an
|
||||||
|
entry in the command registry, so the `?` overlay lists it for the same reason.
|
||||||
|
|
||||||
|
An attached xterm swallows every keystroke it is given, so the web binding is
|
||||||
|
handled in the **capture phase**, ahead of the terminal — the same way the
|
||||||
|
restart overlay swallows the app's shortcuts (`docs/RESTART.md`).
|
||||||
|
|
||||||
|
### Escape hatch — sending a literal Ctrl-K
|
||||||
|
|
||||||
|
Binding `ctrl-k` takes readline's kill-to-end-of-line away from every pane. The
|
||||||
|
leader gives it back: **leader then `ctrl-k`** — `ctrl-a ctrl-k` by default —
|
||||||
|
sends the literal `0x0B` byte to the focused pane and does not open the bar.
|
||||||
|
|
||||||
|
The TUI already has a leader (`ctrl-a`, `[tui].leader`), and this chord goes
|
||||||
|
through the existing chord resolution, so rebinding the leader rebinds the
|
||||||
|
escape hatch with it — exactly as the leader's Insert-mode escape already
|
||||||
|
works. **The web gains a leader for this**, defaulting to `ctrl-a` and
|
||||||
|
mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
||||||
|
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||||
|
useful.
|
||||||
|
|
||||||
|
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
|
||||||
|
`GET /api/info`, and the web binds that instead of its own constant (#159). A
|
||||||
|
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
|
||||||
|
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
|
||||||
|
every client, not the TUI's alone.
|
||||||
|
|
||||||
|
The leader itself needs the same kind of way back: holding it consumes every
|
||||||
|
press, on both clients, even before anything is bound behind it. Pressing it
|
||||||
|
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||||
|
focused pane and clears the pending chord, the way tmux resolves a doubled
|
||||||
|
prefix key. This chord is also built from the configured leader, so a
|
||||||
|
rebound leader carries its own escape hatch with it.
|
||||||
|
|
||||||
|
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
||||||
|
character is intercepted, so none of them needs one.
|
||||||
|
|
||||||
|
### Focus round-trip
|
||||||
|
|
||||||
|
Closing the bar returns the keyboard to whatever held it when the bar opened,
|
||||||
|
the terminal included. A round trip through the bar never silently moves focus,
|
||||||
|
and never counts as a focus change for the daemon: opening the bar does not
|
||||||
|
report the pane unfocused, and closing it does not report a new focus. The bar
|
||||||
|
is a view over the app, not a place in it.
|
||||||
|
|
||||||
|
Opening the bar while a session is previewed (`docs/PREVIEW.md`) discards the
|
||||||
|
preview first, like any other real change; the bar is not previewed into.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
The bar is a query line over a result list. By default the list is **flat and
|
||||||
|
mixed** — sessions and commands ranked together — and rendered under **kind
|
||||||
|
headers**: a header row naming the kind, then that kind's results.
|
||||||
|
|
||||||
|
Grouping is a rendering of the ranking, never a reordering of it:
|
||||||
|
|
||||||
|
- Results are ranked as one list by the algorithm below.
|
||||||
|
- A kind's group holds its matches in that rank order.
|
||||||
|
- **Groups are ordered by their best-scoring member.** The first row under the
|
||||||
|
first header is therefore the globally best result, and the highlight starts
|
||||||
|
on it.
|
||||||
|
|
||||||
|
Two sigils, typed as the **first character** of the query, narrow the list to
|
||||||
|
one kind:
|
||||||
|
|
||||||
|
| Sigil | Kind | Candidates | Enter |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `>` | commands | Every entry in the client's command registry — the web registry from the `?` overlay, the TUI's live `(chord, Action)` keymap for the current mode. | Runs the command. |
|
||||||
|
| `~` | directories | Recent dirs (`GET /api/recent-dirs`) and the project list the spawn form already uses, with `GET /api/dirs` filling in as a path is typed. | Spawns a session there with every default resolved. |
|
||||||
|
|
||||||
|
While a sigil is in force there is one kind, so the kind header is dropped. A
|
||||||
|
command row shows its **key chord** beside its label, so the bar teaches the
|
||||||
|
keymap instead of replacing it. A `~` spawn takes the same fast-forward path as
|
||||||
|
`N` / `FastForwardSpawn`: an unresolvable default opens the pre-filled spawn
|
||||||
|
form rather than failing silently.
|
||||||
|
|
||||||
|
**Backspacing past a sigil returns to the mixed list.** The sigil is one
|
||||||
|
character of the query like any other — deleting it is not a special key, it is
|
||||||
|
the query becoming empty again.
|
||||||
|
|
||||||
|
The sigil is only a sigil in first position. `>` or `~` typed anywhere else is
|
||||||
|
a literal query character (a directory query is where a `~` is most likely to
|
||||||
|
be typed twice, and the second one must reach the matcher).
|
||||||
|
|
||||||
|
## Empty query
|
||||||
|
|
||||||
|
With no sigil and an empty query the bar lists **sessions only, most recently
|
||||||
|
focused first**, so `ctrl-k enter` is "back to where I just was". Commands and
|
||||||
|
directories need a sigil or a query to appear; an empty bar is a session
|
||||||
|
switcher.
|
||||||
|
|
||||||
|
**Recency comes from the daemon**, not from client state — the same order in
|
||||||
|
every client, on every device, including one that just loaded the page. The
|
||||||
|
daemon stamps a last-focused time per session and ships it on the session like
|
||||||
|
any other field (#145). Both of the ways focus moves stamp the same field: a
|
||||||
|
client opening `/api/attach/:id`, and a client reporting focus explicitly at
|
||||||
|
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
||||||
|
|
||||||
|
**A session that has never been focused sorts after every session that has**,
|
||||||
|
and among those, most recently created first. Sessions with no live pane —
|
||||||
|
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
|
||||||
|
session you cannot attach to is not where you just were.
|
||||||
|
|
||||||
|
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||||
|
lists recent dirs, most recent first, then the project list.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `enter` | Open the highlighted result — attach the session, run the command, spawn into the directory. |
|
||||||
|
| `tab` | Drill into the highlighted **session**'s action page. No effect on any other kind. |
|
||||||
|
| `esc` | At the top level, close the bar. On an action page, return to the result list. |
|
||||||
|
| `backspace` | Ordinary editing; at an empty query on an action page, return to the result list. |
|
||||||
|
| `up` / `down` | Move the highlight. |
|
||||||
|
| `ctrl-p` / `ctrl-n` | Move the highlight. |
|
||||||
|
|
||||||
|
The highlight does not wrap: it stops at the first and last row. Kind headers
|
||||||
|
are never landed on — they are labels, not rows.
|
||||||
|
|
||||||
|
Enter on a session goes through the **same open path** the sidebar's click and
|
||||||
|
the hash router already share, so a bar attach can never leave the layout in a
|
||||||
|
state a click could not produce.
|
||||||
|
|
||||||
|
### Drill-in — the session action page
|
||||||
|
|
||||||
|
`tab` on a highlighted session opens that session's action page inside the same
|
||||||
|
overlay: **kill**, **rename**, **spawn similar**. The page is still fuzzy
|
||||||
|
searchable — the query line keeps working, now over those actions.
|
||||||
|
|
||||||
|
- The overlay names the session being acted on, so the target is never
|
||||||
|
ambiguous.
|
||||||
|
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||||
|
previous query intact**. Drilling in and backing out is free.
|
||||||
|
- Each action raises the flow it already has: kill through the confirmation
|
||||||
|
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||||
|
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
|
||||||
|
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
|
||||||
|
seeded from the session's directory and command. The bar picks the action; it
|
||||||
|
never reimplements one.
|
||||||
|
|
||||||
|
No actions beyond those three, and no action across more than one session.
|
||||||
|
|
||||||
|
## Touch entry
|
||||||
|
|
||||||
|
On the web, **tapping or focusing the sidebar's search field opens the bar**
|
||||||
|
with an empty query. There is no `ctrl-k` on a phone and the product is
|
||||||
|
mobile-first, so the field the operator already reaches for is the entry point.
|
||||||
|
|
||||||
|
This **replaces** the sidebar's inline substring filter — one search affordance
|
||||||
|
in the header, not two beside each other. The tree's "No matches" empty state
|
||||||
|
belonged to that filter and goes with it; the bar's own no-results state takes
|
||||||
|
over (see the fixture `F4`).
|
||||||
|
|
||||||
|
At phone width the bar follows the bottom-sheet treatment the spawn modal
|
||||||
|
already uses below 600px. Visuals otherwise come from `DESIGN.md`'s existing
|
||||||
|
overlay tokens — the bar is the same family of surface as the spawn modal and
|
||||||
|
the help overlay, not a new one.
|
||||||
|
|
||||||
|
## What a candidate is
|
||||||
|
|
||||||
|
The matcher is a pure function: `(query, candidates) → ranked subset with
|
||||||
|
matched ranges`. It knows nothing about sessions, panes or the network. A
|
||||||
|
candidate is a **kind** plus a set of **named text fields**, each with a fixed
|
||||||
|
weight.
|
||||||
|
|
||||||
|
| Kind | Field | Weight | Text |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| session | `name` | `0` | The name as the sidebar shows it: `metadata.name` if set, else the command line (`docs/SIDEBAR.md`'s fallback). |
|
||||||
|
| session | `directory` | `-24` | The directory the sidebar groups it under, abbreviated: `~/dev/api`, not `/home/me/dev/api`. |
|
||||||
|
| session | `command` | `-32` | The full command line, arguments included. |
|
||||||
|
| session | `tool` | `-40` | The tool id the card's icon stands for (`claude`, `codex`, `opencode`, …), absent when the program is not one we have a mark for. |
|
||||||
|
| command | `label` | `0` | The plain-language label — the web registry's label, the TUI's `Action::help_label`. |
|
||||||
|
| directory | `path` | `0` | The path as displayed, abbreviated the same way. |
|
||||||
|
|
||||||
|
A command's **key chord is displayed, not matched**. It is there to teach the
|
||||||
|
keymap; matching it would make `>` results depend on a user's rebinds, and the
|
||||||
|
two clients do not share a keymap.
|
||||||
|
|
||||||
|
A candidate **matches** if at least one of its fields matches. Its score is the
|
||||||
|
**best** `field score + field weight` across its fields, and the matched ranges
|
||||||
|
it returns are those of that winning field — so a session found through its
|
||||||
|
directory highlights inside the directory, not inside its name.
|
||||||
|
|
||||||
|
## Match and rank
|
||||||
|
|
||||||
|
Two independent implementations (#146 in TypeScript, #147 in Rust) must produce
|
||||||
|
the same order for the same input. Everything below is exact.
|
||||||
|
|
||||||
|
### Normalization
|
||||||
|
|
||||||
|
- **Query**: lowercase, then remove every whitespace character. The result is
|
||||||
|
the *normalized query*. If it is empty, see "Empty query" below.
|
||||||
|
- **Field text**: matched against its lowercased form. Bonuses are computed
|
||||||
|
against the **original** text, because case is a signal (`camelCase`) that
|
||||||
|
lowercasing destroys.
|
||||||
|
- **Compact form** of a string: lowercase with whitespace removed. Used only
|
||||||
|
for the exact and prefix tests.
|
||||||
|
|
||||||
|
Lowercasing is per-character simple lowercase (`char::to_lowercase` /
|
||||||
|
`String.prototype.toLowerCase`). Positions are **character** positions —
|
||||||
|
Unicode scalar values in Rust, UTF-16 code units in TypeScript, which agree for
|
||||||
|
every character these fields realistically hold; ranges are returned in the
|
||||||
|
host language's own unit, since they exist to slice that language's string.
|
||||||
|
|
||||||
|
### What a match is
|
||||||
|
|
||||||
|
Case-insensitive **subsequence**: every character of the normalized query
|
||||||
|
appears in the lowercased field text, in order, not necessarily adjacent. A
|
||||||
|
field with no such embedding does not match.
|
||||||
|
|
||||||
|
### Word boundaries
|
||||||
|
|
||||||
|
A matched character at position `p` earns exactly one character bonus, taking
|
||||||
|
the first rule that applies:
|
||||||
|
|
||||||
|
| Rule | Bonus |
|
||||||
|
| --- | --- |
|
||||||
|
| `p == 0` — first character of the field | `+18` |
|
||||||
|
| The character before it is a **separator**: `/` `\` `-` `_` `.` `:` or a space | `+16` |
|
||||||
|
| It is uppercase and the character before it is lowercase (a camelCase hump) | `+12` |
|
||||||
|
| It is a digit and the character before it is not | `+12` |
|
||||||
|
| Otherwise | `0` |
|
||||||
|
|
||||||
|
That is one rule for both shapes the doc has to cover. In a **path**, `/` is a
|
||||||
|
separator, so the first character of every segment is a word start: `foo` in
|
||||||
|
`~/dev/foobar` starts a segment and outscores `foo` in `~/src/myfoo`, which
|
||||||
|
starts nothing. In a **session name**, `-`, `_`, `.` and spaces do the same
|
||||||
|
work, so `ws` reaches `web-search` more cheaply than `websearch`.
|
||||||
|
|
||||||
|
### Score
|
||||||
|
|
||||||
|
Let the query characters be matched at positions `p₀ < p₁ < … < pₘ₋₁`, and let
|
||||||
|
`p₋₁ = -1`. For each matched character `i`, with `gap = pᵢ - pᵢ₋₁ - 1`:
|
||||||
|
|
||||||
|
```
|
||||||
|
contribution(i) = 16 // every matched character
|
||||||
|
+ bonus(pᵢ) // the table above
|
||||||
|
+ (gap == 0 ? +8 // adjacent to the previous match
|
||||||
|
: -min(gap, 12))
|
||||||
|
```
|
||||||
|
|
||||||
|
`gap` for `i = 0` is the number of characters skipped before the first match, so
|
||||||
|
a match that starts late is penalised exactly like an internal gap, and the cap
|
||||||
|
is the same one.
|
||||||
|
|
||||||
|
The **field score** is the sum of the contributions, plus at most one whole-field
|
||||||
|
adjustment:
|
||||||
|
|
||||||
|
- compact field text **equals** the normalized query → `+64`
|
||||||
|
- else compact field text **starts with** the normalized query → `+32`
|
||||||
|
|
||||||
|
Length is deliberately not in the score — it is a tie-break, so a long field
|
||||||
|
never loses to a short one that matched worse.
|
||||||
|
|
||||||
|
### Which embedding
|
||||||
|
|
||||||
|
A query can sit in a field many ways. The one that counts is the
|
||||||
|
**highest-scoring** embedding; among embeddings that score the same, the
|
||||||
|
**leftmost** — compare position vectors element by element, smaller wins.
|
||||||
|
|
||||||
|
Greedy leftmost-first matching is not sufficient and must not be used: in
|
||||||
|
`maestro-tui`, `mtu` greedily takes `m₀ a₁ …` and never reaches `tui`. Both
|
||||||
|
implementations use the obvious dynamic program over
|
||||||
|
`(query index, text position)`, carrying the best score and its position
|
||||||
|
vector, since the gap term depends only on the previous matched position.
|
||||||
|
|
||||||
|
The whole-field adjustment is constant for a `(query, field)` pair, so adding it
|
||||||
|
after the dynamic program cannot change which embedding wins.
|
||||||
|
|
||||||
|
### Ordering
|
||||||
|
|
||||||
|
Sort by score, highest first. Equal scores break in this order:
|
||||||
|
|
||||||
|
1. **Kind** — `session`, then `command`, then `directory`.
|
||||||
|
2. **Length of the matched field text**, shorter first.
|
||||||
|
3. **The matched field text**, ascending, compared by code point, case
|
||||||
|
sensitive.
|
||||||
|
4. **Input order.** The sort is stable, so candidates the caller ordered
|
||||||
|
deliberately — sessions most recently focused first, commands in registry
|
||||||
|
order — keep that order when nothing else separates them.
|
||||||
|
|
||||||
|
Kind leads because that is the ruling the ambiguous cases need: a session named
|
||||||
|
`kill` and the `kill` command that score alike put the session first, because
|
||||||
|
the operator who typed a session's name meant the session.
|
||||||
|
|
||||||
|
### Empty query
|
||||||
|
|
||||||
|
A normalized query that is empty **matches every candidate**, each with score
|
||||||
|
`0` and no ranges, in input order. The bar's own rules decide what is in the
|
||||||
|
candidate list at that point (sessions only at the top level); the matcher does
|
||||||
|
not filter by kind.
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
These are the contract. Both matchers are tested against exactly these cases;
|
||||||
|
the prose above is the explanation of why they come out this way.
|
||||||
|
|
||||||
|
Candidates are written as `kind(fields)`; unlisted fields are absent.
|
||||||
|
`session(name, dir, command, tool)`. Scores are shown for diagnosis — **the
|
||||||
|
order is what is being asserted**, and an implementation that ranks correctly
|
||||||
|
with different absolute numbers has still failed, because the numbers are how
|
||||||
|
the next fixture stays predictable.
|
||||||
|
|
||||||
|
### F1 — empty query keeps input order
|
||||||
|
|
||||||
|
```
|
||||||
|
query: ""
|
||||||
|
candidates: session("kill", "~/dev/api", "bash")
|
||||||
|
session("killer-app", "~/dev/killer-app", "claude --resume", "claude")
|
||||||
|
command("Kill selected session")
|
||||||
|
expected: session("kill") score 0
|
||||||
|
session("killer-app") score 0
|
||||||
|
command("Kill selected session") score 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Every candidate matches, in the order given. No ranges.
|
||||||
|
|
||||||
|
### F2 — a session named `kill` against the `kill` command
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "kill"
|
||||||
|
candidates: (as F1)
|
||||||
|
expected: session("kill") 178 name
|
||||||
|
session("killer-app") 146 name
|
||||||
|
command("Kill selected session") 146 label
|
||||||
|
```
|
||||||
|
|
||||||
|
`kill` wins on the exact bonus. The other two **tie at 146** — both are a
|
||||||
|
contiguous prefix match — and the kind tie-break puts the session first.
|
||||||
|
|
||||||
|
### F3 — a fragment matching mid-segment inside a path
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "foo"
|
||||||
|
candidates: directory("~/dev/foobar")
|
||||||
|
directory("~/src/myfoo")
|
||||||
|
directory("~/foo")
|
||||||
|
expected: directory("~/foo") 78 ranges [2,3,4]
|
||||||
|
directory("~/dev/foobar") 74 ranges [6,7,8]
|
||||||
|
directory("~/src/myfoo") 56 ranges [8,9,10]
|
||||||
|
```
|
||||||
|
|
||||||
|
The first two start a path segment; the third is mid-segment and loses by the
|
||||||
|
whole boundary bonus. `~/foo` beats `~/dev/foobar` only because less was
|
||||||
|
skipped before the match.
|
||||||
|
|
||||||
|
### F4 — a query matching nothing
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "zzz"
|
||||||
|
candidates: session("kill", "~/dev/api", "bash")
|
||||||
|
command("Kill selected session")
|
||||||
|
expected: (empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
The matcher returns an empty list, never a list of zero-scored candidates. The
|
||||||
|
bar renders its no-results state.
|
||||||
|
|
||||||
|
### F5 — a non-contiguous subsequence
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "mtu"
|
||||||
|
candidates: session("maestro-tui")
|
||||||
|
session("maestro-daemon")
|
||||||
|
session("mtui-scratch")
|
||||||
|
expected: session("mtui-scratch") 122 ranges [0,1,2]
|
||||||
|
session("maestro-tui") 91 ranges [0,8,9]
|
||||||
|
```
|
||||||
|
|
||||||
|
`maestro-daemon` has no `u` after its `t` and does not match. `maestro-tui`
|
||||||
|
does, across a gap — which greedy matching would miss entirely.
|
||||||
|
|
||||||
|
### F6 — a name beats a directory
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "api"
|
||||||
|
candidates: session("api", "~/dev/web", "bash")
|
||||||
|
session("worker", "~/dev/api", "bash")
|
||||||
|
expected: session("api") 154 name
|
||||||
|
session("worker") 50 directory
|
||||||
|
```
|
||||||
|
|
||||||
|
The second session is found through its directory, and highlights there.
|
||||||
|
|
||||||
|
### F7 — case-insensitive, tie broken by length
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "api"
|
||||||
|
candidates: session("API-Gateway")
|
||||||
|
session("apiary")
|
||||||
|
expected: session("apiary") 122 name
|
||||||
|
session("API-Gateway") 122 name
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are prefix matches and score identically. Same kind, so length decides:
|
||||||
|
6 characters before 11. Input order does not save `API-Gateway`.
|
||||||
|
|
||||||
|
### F8 — the tool field
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "claude"
|
||||||
|
candidates: session("nameless", "~/dev/x", "claude --resume", "claude")
|
||||||
|
session("clay", "~/dev/y", "bash")
|
||||||
|
expected: session("nameless") 186 tool
|
||||||
|
```
|
||||||
|
|
||||||
|
`clay` has no `claude` subsequence anywhere and drops out. `nameless` matches
|
||||||
|
its `tool` exactly (`+64`, weight `-40`, giving 186) which beats the prefix
|
||||||
|
match on its `command` field (`+32`, weight `-32`, giving 162).
|
||||||
|
|
||||||
|
### F9 — a word boundary inside a session name
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "ws"
|
||||||
|
candidates: session("websearch")
|
||||||
|
session("web-search")
|
||||||
|
expected: session("web-search") 71 ranges [0,4]
|
||||||
|
session("websearch") 56 ranges [0,3]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `s` after `-` earns the boundary bonus; the `s` inside `websearch` earns
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
### F10 — command labels
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "se"
|
||||||
|
candidates: command("Kill selected session")
|
||||||
|
command("Create a session")
|
||||||
|
command("Show help")
|
||||||
|
expected: command("Show help") 53 ranges [0,6]
|
||||||
|
command("Kill selected session") 51 ranges [5,6]
|
||||||
|
command("Create a session") 47 ranges [9,10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Show help` starts at position 0; the other two start at a word boundary but
|
||||||
|
later, and pay for the skipped characters.
|
||||||
|
|
||||||
|
### F11 — a query against session directories
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "dev"
|
||||||
|
candidates: session("api", "~/dev/api", "bash")
|
||||||
|
session("notes", "~/documents/dev-notes", "nvim")
|
||||||
|
session("scratch", "~/srv/devops", "bash")
|
||||||
|
expected: session("api") 54 directory, ranges [2,3,4]
|
||||||
|
session("scratch") 50 directory, ranges [6,7,8]
|
||||||
|
session("notes") 44 directory, ranges [12,13,14]
|
||||||
|
```
|
||||||
|
|
||||||
|
All three start a path segment. They separate purely on how much was skipped
|
||||||
|
first, and `notes` hits the `-12` gap cap.
|
||||||
|
|
||||||
|
### F12 — exact beats prefix
|
||||||
|
|
||||||
|
```
|
||||||
|
query: "quit"
|
||||||
|
candidates: command("Quit Maestro")
|
||||||
|
command("Quit")
|
||||||
|
expected: command("Quit") 178 label
|
||||||
|
command("Quit Maestro") 146 label
|
||||||
|
```
|
||||||
|
|
||||||
|
The 32-point difference between the exact and prefix adjustments is the whole
|
||||||
|
gap; nothing else about the two matches differs.
|
||||||
+4
-2
@@ -136,12 +136,14 @@ cover every v1 action — only override what you want to change:
|
|||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
theme = "dark" # see the theme list below
|
theme = "dark" # see the theme list below
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
|
||||||
|
# the same setting off /api/info
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "OpenSpawnModal"
|
"n" = "OpenSpawnModal"
|
||||||
|
"N" = "FastForwardSpawn"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
"r" = "RenameHighlighted"
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# PREVIEW.md — the sidebar preview contract
|
||||||
|
|
||||||
|
One rule for what the content pane shows while the operator is only moving
|
||||||
|
around the sidebar, so the web client and the TUI behave the same. Read this
|
||||||
|
before changing either. If the behaviour needs to change, change it here first.
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
Highlighting a session in the sidebar shows that session in the content pane,
|
||||||
|
as if it were focused, **without focusing it and without changing the layout**.
|
||||||
|
Moving the highlight elsewhere puts back exactly what was on screen before.
|
||||||
|
|
||||||
|
A preview is a view, not a state. Nothing about it is durable:
|
||||||
|
|
||||||
|
- The layout is byte-for-byte identical before the preview and after it ends.
|
||||||
|
- Nothing is persisted while a preview is up (the web client's layout is saved
|
||||||
|
to `localStorage`; a preview never reaches it).
|
||||||
|
- The daemon is never told the previewed pane is focused, so a preview never
|
||||||
|
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
|
||||||
|
- The keyboard stays in the sidebar: the previewed terminal is on screen but
|
||||||
|
does not take focus, so the next `j`/`k` still walks the list.
|
||||||
|
|
||||||
|
## Where the preview is shown
|
||||||
|
|
||||||
|
| The previewed session is… | What happens |
|
||||||
|
|---|---|
|
||||||
|
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
|
||||||
|
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
||||||
|
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
||||||
|
|
||||||
|
The throwaway tab never counts as an open session: the tab bar does not appear
|
||||||
|
because of it, the mobile shell does not switch away from the session list for
|
||||||
|
it, and the sidebar does not mark the session as open.
|
||||||
|
|
||||||
|
## How a preview reads
|
||||||
|
|
||||||
|
A preview is provisional, so it never wears the accent — the accent is what
|
||||||
|
says "this is the one you picked". It has a hue of its own, and every surface
|
||||||
|
involved in the preview carries that same hue, so the pane and the row it came
|
||||||
|
from read as one thing rather than two unrelated highlights.
|
||||||
|
|
||||||
|
Web client (`--preview` and friends in `web/src/styles.css`):
|
||||||
|
|
||||||
|
| Surface | While previewing |
|
||||||
|
|---|---|
|
||||||
|
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||||
|
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||||
|
| A throwaway tab | Dashed border, italic label. |
|
||||||
|
| The sidebar row being previewed | Filled with the preview hue, over whatever open 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. |
|
||||||
|
|
||||||
|
Rows say it with fill, not with an edge — see the tier ladder in
|
||||||
|
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
|
||||||
|
belong to the pane: the canvas ring and the throwaway tab.
|
||||||
|
|
||||||
|
The hue is a palette token, not a fixed colour: a palette whose accent already
|
||||||
|
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
|
||||||
|
|
||||||
|
The TUI marks a preview in text only — the title bar reads `<name> · preview`
|
||||||
|
— and has no preview colour role yet.
|
||||||
|
|
||||||
|
## What ends a preview
|
||||||
|
|
||||||
|
- The highlight moves to another session — that session is previewed instead.
|
||||||
|
- The highlight leaves every session (a folder row, or the list losing the
|
||||||
|
keyboard, or the pointer leaving the list): the previous view comes back.
|
||||||
|
- Any real layout change (split, close, new tab, tab switch, drag): the preview
|
||||||
|
is discarded first, so a throwaway tab is never baked into the arrangement.
|
||||||
|
|
||||||
|
## What commits a preview
|
||||||
|
|
||||||
|
Choosing the previewed session — clicking or pressing Enter on its row,
|
||||||
|
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
|
||||||
|
the list; TUI: the attach binding) — keeps exactly what is on screen. A
|
||||||
|
throwaway tab becomes a real one; a focus move inside an existing tab stands.
|
||||||
|
Nothing moves a second time on commit.
|
||||||
|
|
||||||
|
## Timing
|
||||||
|
|
||||||
|
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
|
||||||
|
shows it on the next frame, and so does leaving every row. There is no
|
||||||
|
settling period in front of the first preview, because that period would sit
|
||||||
|
in front of the one preview the operator actually meant.
|
||||||
|
|
||||||
|
What is bounded is how *often* the pane may swap: at most once per 120 ms.
|
||||||
|
A burst inside one window — `j` held down, or the pointer sweeping the length
|
||||||
|
of the sidebar — shows the row it started on, then the row it ended on, and
|
||||||
|
none of the rows in between. So a walk costs a couple of pane mounts rather
|
||||||
|
than one per row, without the operator ever waiting on one.
|
||||||
|
|
||||||
|
The highlight itself is not bounded by any of this: the row lights up on the
|
||||||
|
same frame as the input, whatever the pane behind it is doing.
|
||||||
|
|
||||||
|
Touch has no hover, and a tap already opens a session, so touch never previews.
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
A previewed session that no pane holds needs a live attach. Both clients keep
|
||||||
|
it connected after the preview ends — the web client parks it in the connection
|
||||||
|
pool's LRU, the TUI keeps its emulator until it falls out of the preview
|
||||||
|
budget — so stepping back onto a row is instant rather than re-fetching
|
||||||
|
scrollback.
|
||||||
@@ -68,6 +68,26 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
|||||||
moment a probe succeeds — there is no "retry" button, because the client is
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
already retrying.
|
already retrying.
|
||||||
|
|
||||||
|
## What survives, and what comes back dormant
|
||||||
|
|
||||||
|
A daemon restart leaves tmux alone, so recovery re-adopts every
|
||||||
|
`maestro-<uuid>` session tmux still holds and the operator sees no change
|
||||||
|
beyond the reconnect above.
|
||||||
|
|
||||||
|
A **host reboot** is different: it kills the tmux server, so there is nothing
|
||||||
|
to adopt while every session's sidecar is still on disk. Recovery makes a
|
||||||
|
second pass over those sidecars and registers each as a `Dormant` session
|
||||||
|
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
||||||
|
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
||||||
|
resized, and neither watcher pass touches it. It is a record of what was
|
||||||
|
running, and the only thing left to do with one is dismiss it, which removes
|
||||||
|
the sidecar so the next recovery does not bring it back.
|
||||||
|
|
||||||
|
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
|
||||||
|
a spelling this build does not know reads as `Dead` rather than failing the
|
||||||
|
whole session. Clients render a dormant session as known-but-not-running —
|
||||||
|
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
|
||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
|||||||
+76
-5
@@ -56,7 +56,7 @@ itself is not.
|
|||||||
Line 2 reads left to right:
|
Line 2 reads left to right:
|
||||||
|
|
||||||
```
|
```
|
||||||
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
|
||||||
```
|
```
|
||||||
|
|
||||||
Its minimum content is therefore just the command.
|
Its minimum content is therefore just the command.
|
||||||
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
|
|||||||
while an agent-reported state is in force (see Precedence): when the report
|
while an agent-reported state is in force (see Precedence): when the report
|
||||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
|
||||||
client ever lets it wrap.
|
neither client ever lets it wrap.
|
||||||
|
|
||||||
|
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
|
||||||
|
only from its sidecar: recovery found no tmux session behind it, so it has no
|
||||||
|
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
||||||
|
observed ending — so the two fragments never both show. The TUI marks the same
|
||||||
|
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||||
|
|
||||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||||
@@ -177,7 +183,39 @@ surface; a colour chosen for the card background is never painted onto the
|
|||||||
selection background. Contrast is a property of the palette (values are RGB)
|
selection background. Contrast is a property of the palette (values are RGB)
|
||||||
and is asserted in the palette's tests, not left to the terminal theme.
|
and is asserted in the palette's tests, not left to the terminal theme.
|
||||||
|
|
||||||
The duration text inherits its state's colour; the exited and command
|
## Which row is filled
|
||||||
|
|
||||||
|
Both clients say every one of these with a **background fill**, never an edge.
|
||||||
|
A row is usually several of them at once, so they form a ladder: the tier
|
||||||
|
furthest down wins the paint.
|
||||||
|
|
||||||
|
| Tier | Web | TUI |
|
||||||
|
|---|---|---|
|
||||||
|
| Alive | 6% accent wash | — (the rail carries it) |
|
||||||
|
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||||
|
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||||
|
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||||
|
| Previewing (`docs/PREVIEW.md`), 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
|
||||||
|
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||||
|
*is* the precedence. Add a tier where it belongs in that run, never by piling
|
||||||
|
on another class to out-weigh the rule above it.
|
||||||
|
|
||||||
|
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
|
||||||
|
since those are the colours picked for a filled row. `pane_row_bg()` is
|
||||||
|
blended from the two surfaces rather than shipped as a third one — a third
|
||||||
|
surface would mean eight more colours hand-picked seventeen times — and the
|
||||||
|
palette tests assert it stays a visible step from both. The cursor's fill wins
|
||||||
|
on its own row, so a highlight parked on the session already in the pane is
|
||||||
|
still visible.
|
||||||
|
|
||||||
|
The one edge left in either list is the TUI's `▏` marker, which stands in for
|
||||||
|
the cursor's fill while the pane holds the keyboard, and the web's ring on a
|
||||||
|
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||||
|
step up to.
|
||||||
|
|
||||||
|
The duration text inherits its state's colour; the exited, dormant and command
|
||||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||||
take the `active` and `error` roles of the same surface.
|
take the `active` and `error` roles of the same surface.
|
||||||
@@ -234,7 +272,7 @@ the agent, and answering 204 would hide it.
|
|||||||
|
|
||||||
A session's pane — the pane a client's own attach view is showing, never a
|
A session's pane — the pane a client's own attach view is showing, never a
|
||||||
sidebar highlight — clears its `AwaitingInput` glow once it has been
|
sidebar highlight — clears its `AwaitingInput` glow once it has been
|
||||||
**continuously focused for 5 seconds**: the operator has looked, so the rail
|
**continuously focused for 1 second**: the operator has looked, so the rail
|
||||||
stops asking for attention and the card reads as `Idle` instead. This is a
|
stops asking for attention and the card reads as `Idle` instead. This is a
|
||||||
real state change, not a client-side paint-over — the daemon computes it, so
|
real state change, not a client-side paint-over — the daemon computes it, so
|
||||||
every client watching the session (web, TUI, a second pane on the same
|
every client watching the session (web, TUI, a second pane on the same
|
||||||
@@ -274,3 +312,36 @@ than jumping to the end of the list.
|
|||||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||||
`ctrl-w h`.
|
`ctrl-w h`.
|
||||||
|
|
||||||
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---------|---------------------------------------------|
|
||||||
|
| `j`/`k` | move the highlight (arrows too) |
|
||||||
|
| `Enter` | open the session, or expand the folder |
|
||||||
|
| `Tab` | hand the keyboard to the pane |
|
||||||
|
| `c` | collapse or expand the highlighted folder |
|
||||||
|
| `r` | rename the highlighted session |
|
||||||
|
| `x` | kill the highlighted session (confirms) |
|
||||||
|
| `n` | open the spawn form |
|
||||||
|
| `N` | spawn with every default resolved |
|
||||||
|
| `?` | show the shortcut overlay |
|
||||||
|
|
||||||
|
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||||
|
web client's are fixed. A binding added to one client belongs in this table and
|
||||||
|
in both help overlays.
|
||||||
|
|
||||||
|
## Killing a session
|
||||||
|
|
||||||
|
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||||
|
and unattended agents live in these sessions, so neither client sends
|
||||||
|
`DELETE /api/sessions/:id` off a single press. The TUI raises
|
||||||
|
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
|
||||||
|
`✕` button and the command bar's action page both use. Cancel holds the focus
|
||||||
|
in the web's dialog, so nothing kills on a stray Enter.
|
||||||
|
|
||||||
|
The confirmation names what the card last saw in the way, when anything was —
|
||||||
|
the daemon will refuse an unforced kill over such a worktree, and saying so
|
||||||
|
before the attempt is cheaper than a refusal. What comes back from that refusal
|
||||||
|
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
|
||||||
|
what a forced kill leaves behind.
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
@@ -218,8 +218,8 @@
|
|||||||
settings = {
|
settings = {
|
||||||
# `nix run .#dev` works from a bare shell, not only from inside
|
# `nix run .#dev` works from a bare shell, not only from inside
|
||||||
# `nix develop`, so the toolchain is put on PATH here rather than
|
# `nix develop`, so the toolchain is put on PATH here rather than
|
||||||
# inherited. `/usr/bin:/bin` stays last for anything else a
|
# inherited. This covers the build and Vite; the daemon widens it
|
||||||
# spawned session reaches for.
|
# further for the sessions it spawns (see `daemon.command`).
|
||||||
environment = [
|
environment = [
|
||||||
"PATH=${lib.makeBinPath devTools}:/usr/bin:/bin"
|
"PATH=${lib.makeBinPath devTools}:/usr/bin:/bin"
|
||||||
# Not :7070: that is the daemon's own default, and the dev
|
# Not :7070: that is the daemon's own default, and the dev
|
||||||
@@ -241,7 +241,17 @@
|
|||||||
# `environment`. A tmux socket of its own keeps dev sessions
|
# `environment`. A tmux socket of its own keeps dev sessions
|
||||||
# out of — and safe from — the tmux server belonging to
|
# out of — and safe from — the tmux server belonging to
|
||||||
# whatever daemon the developer actually runs.
|
# whatever daemon the developer actually runs.
|
||||||
|
#
|
||||||
|
# A spawned session inherits the daemon's PATH, and the
|
||||||
|
# toolchain PATH above holds build tools only — on NixOS the
|
||||||
|
# trailing `/usr/bin:/bin` is all but empty, so every session
|
||||||
|
# opened from the dev UI came up unable to find `claude`,
|
||||||
|
# `zsh` or anything else the developer actually has. Append
|
||||||
|
# the usual user profile directories so dev sessions behave
|
||||||
|
# like real ones. Resolved by the shell at run time; the ones
|
||||||
|
# that do not exist on a given host cost nothing.
|
||||||
command = ''
|
command = ''
|
||||||
|
PATH="$PATH:$HOME/.nix-profile/bin:/etc/profiles/per-user/$(id -un)/bin:/run/current-system/sw/bin:/usr/local/bin" \
|
||||||
MAESTRO_DAEMON_TMUX_TMPDIR="$PWD/.dev/tmux" \
|
MAESTRO_DAEMON_TMUX_TMPDIR="$PWD/.dev/tmux" \
|
||||||
RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \
|
RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \
|
||||||
target/debug/maestro daemon
|
target/debug/maestro daemon
|
||||||
|
|||||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0"
|
"@xterm/xterm": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+8
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||||
|
'@xterm/addon-webgl':
|
||||||
|
specifier: ^0.19.0
|
||||||
|
version: 0.19.0
|
||||||
'@xterm/xterm':
|
'@xterm/xterm':
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
@@ -348,6 +351,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@xterm/xterm': ^5.0.0
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
|
'@xterm/addon-webgl@0.19.0':
|
||||||
|
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||||
|
|
||||||
'@xterm/xterm@5.5.0':
|
'@xterm/xterm@5.5.0':
|
||||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||||
|
|
||||||
@@ -772,6 +778,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@xterm/xterm': 5.5.0
|
'@xterm/xterm': 5.5.0
|
||||||
|
|
||||||
|
'@xterm/addon-webgl@0.19.0': {}
|
||||||
|
|
||||||
'@xterm/xterm@5.5.0': {}
|
'@xterm/xterm@5.5.0': {}
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|||||||
+111
-12
@@ -1,15 +1,31 @@
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
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";
|
||||||
import { requestToggleDrawer } from "./drawer.js";
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
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";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* rationed rather than handed to every connection. */
|
||||||
|
const webglPolicy = new WebglPolicy();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
||||||
@@ -26,12 +42,18 @@ export interface AttachConnection {
|
|||||||
readonly sessionId: Uuid;
|
readonly sessionId: Uuid;
|
||||||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||||||
readonly el: HTMLElement;
|
readonly el: HTMLElement;
|
||||||
/** Becomes the visible view: fit to the pane, focus, sync size. */
|
/** Becomes the visible view: fit to the pane, focus, sync size.
|
||||||
onMount(): void;
|
* `focus: false` mounts without taking the keyboard — what a sidebar
|
||||||
|
* preview needs, so j/k keeps working while the pane is on screen. */
|
||||||
|
onMount(opts?: { focus?: boolean }): void;
|
||||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -48,8 +70,6 @@ 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 {
|
||||||
@@ -135,13 +155,79 @@ export function createAttachConnection(
|
|||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.open(body);
|
term.open(body);
|
||||||
|
|
||||||
|
// GPU rendering is scoped to the mounted view, not the connection: acquired
|
||||||
|
// in onMount, released in onUnmount. See webglRenderer.ts.
|
||||||
|
let webgl: WebglAddon | null = null;
|
||||||
|
let dprMedia: MediaQueryList | null = null;
|
||||||
|
|
||||||
|
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
|
||||||
|
// window to a display with a different one (or an Android browser-zoom
|
||||||
|
// change) leaves every cell blurry until the atlas is rebuilt.
|
||||||
|
const onDprChange = () => {
|
||||||
|
try {
|
||||||
|
term.clearTextureAtlas();
|
||||||
|
} catch {
|
||||||
|
// ignore — nothing to rebuild if the renderer already went away
|
||||||
|
}
|
||||||
|
watchDpr();
|
||||||
|
};
|
||||||
|
|
||||||
|
function watchDpr(): void {
|
||||||
|
dprMedia?.removeEventListener("change", onDprChange);
|
||||||
|
dprMedia = null;
|
||||||
|
try {
|
||||||
|
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
||||||
|
dprMedia?.addEventListener("change", onDprChange);
|
||||||
|
} catch {
|
||||||
|
// ignore — a browser without `resolution` media queries just keeps the
|
||||||
|
// atlas it has, which is the pre-WebGL behaviour anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseWebgl(): void {
|
||||||
|
dprMedia?.removeEventListener("change", onDprChange);
|
||||||
|
dprMedia = null;
|
||||||
|
if (!webgl) return;
|
||||||
|
const addon = webgl;
|
||||||
|
webgl = null;
|
||||||
|
try {
|
||||||
|
addon.dispose(); // xterm falls back to the DOM renderer
|
||||||
|
} catch {
|
||||||
|
// ignore — a lost context can throw on the way out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireWebgl(): void {
|
||||||
|
if (webgl || disposed || !webglPolicy.enabled()) return;
|
||||||
|
try {
|
||||||
|
const addon = new WebglAddon();
|
||||||
|
// Context loss is routine on Android — backgrounding the tab is enough.
|
||||||
|
// Drop back to the DOM renderer, then retry only while the shared budget
|
||||||
|
// allows and this view is still the one on screen.
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
webglPolicy.noteContextLoss();
|
||||||
|
releaseWebgl();
|
||||||
|
if (mounted) acquireWebgl();
|
||||||
|
});
|
||||||
|
term.loadAddon(addon);
|
||||||
|
webgl = addon;
|
||||||
|
watchDpr();
|
||||||
|
} catch {
|
||||||
|
// Context creation failed outright (no GPU, or the browser is already at
|
||||||
|
// its context cap). Count it like a loss so we stop asking, and stay on
|
||||||
|
// the DOM renderer.
|
||||||
|
webglPolicy.noteContextLoss();
|
||||||
|
webgl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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" || e.key !== "Escape") return true;
|
if (e.type !== "keydown") return true;
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
return !paneKeys.handle(e);
|
||||||
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
|
||||||
@@ -237,6 +323,10 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
// A previewed pane is visible but must not steal the keyboard from the
|
||||||
|
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
||||||
|
// the socket does when it finishes connecting).
|
||||||
|
let focusOnMount = true;
|
||||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||||||
@@ -315,7 +405,7 @@ export function createAttachConnection(
|
|||||||
reconnectMs = INITIAL_RECONNECT_MS;
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
setState("connected", "open");
|
setState("connected", "open");
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
if (mounted) term.focus();
|
if (mounted && focusOnMount) term.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
@@ -400,21 +490,29 @@ export function createAttachConnection(
|
|||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
el,
|
el,
|
||||||
onMount() {
|
onMount(mountOpts?: { focus?: boolean }) {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
focusOnMount = mountOpts?.focus !== false;
|
||||||
|
acquireWebgl();
|
||||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
tryFit();
|
tryFit();
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
if (ws.readyState === WebSocket.OPEN) term.focus();
|
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onUnmount() {
|
onUnmount() {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
// Parked views hand their GPU context back: the browser's cap is small
|
||||||
|
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
|
||||||
|
releaseWebgl();
|
||||||
},
|
},
|
||||||
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;
|
||||||
@@ -427,6 +525,7 @@ export function createAttachConnection(
|
|||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
dataDisp.dispose();
|
dataDisp.dispose();
|
||||||
resizeDisp.dispose();
|
resizeDisp.dispose();
|
||||||
|
releaseWebgl();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
//
|
||||||
|
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||||
|
// kill or rename and raises the flow the card already has, so what the page
|
||||||
|
// runs is what the ✕ button and the `r` key run.
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector(".kill-dialog")?.remove();
|
||||||
|
mounted?.dispose();
|
||||||
|
mounted = null;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: { name: "amber-falcon" },
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(s: Session): HTMLElement {
|
||||||
|
const handle = renderSidebar(storeWith([s]));
|
||||||
|
mounted = handle;
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raise(name: string, s: Session): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kill from the action page", () => {
|
||||||
|
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||||
|
"Kill amber-falcon",
|
||||||
|
);
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||||
|
window.fetch = ((url: string) => {
|
||||||
|
if (String(url).includes("force=true")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "kill_guarded",
|
||||||
|
message: "worktree has unsaved work",
|
||||||
|
unsaved_work: { untracked_files: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||||
|
mount(s);
|
||||||
|
raise(KILL_SESSION_EVENT, s);
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rename from the action page", () => {
|
||||||
|
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||||
|
folder?.click();
|
||||||
|
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||||
|
const s = session();
|
||||||
|
const el = mount(s);
|
||||||
|
raise(RENAME_SESSION_EVENT, s);
|
||||||
|
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(input?.value).toBe("amber-falcon");
|
||||||
|
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||||
|
});
|
||||||
|
});
|
||||||
+53
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills straight away, without a first confirmation", async () => {
|
it("confirms before an unguarded kill (#160)", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([plain])).el;
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
||||||
|
blocked_work: { untracked_files: 1 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
expect(dialog?.textContent).toContain("refused unless it is forced");
|
||||||
|
});
|
||||||
|
|
||||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||||
@@ -375,6 +414,19 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line2?.textContent).toContain("exited");
|
expect(line2?.textContent).toContain("exited");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #162: a session recovered with no tmux behind it is known but not running,
|
||||||
|
// and never reads as something that exited.
|
||||||
|
it("shows dormant on line 2 for dormant sessions", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
||||||
|
status: "Dormant",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.textContent).toContain("dormant");
|
||||||
|
expect(line2?.textContent).not.toContain("exited");
|
||||||
|
});
|
||||||
|
|
||||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||||
|
|||||||
+260
-42
@@ -1,11 +1,13 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
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, openKillConfirmDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
import { 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 {
|
||||||
|
abbreviateCwd,
|
||||||
abbreviateFolderName,
|
abbreviateFolderName,
|
||||||
blockerStats,
|
blockerStats,
|
||||||
customSessionName,
|
customSessionName,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
sessionBlockers,
|
sessionBlockers,
|
||||||
isProvisionalName,
|
isProvisionalName,
|
||||||
sessionName,
|
sessionName,
|
||||||
|
sessionTreePath,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +39,15 @@ export function requestOpenSpawn(): void {
|
|||||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||||
|
* the sidebar's search field, which is the touch entry point to the bar
|
||||||
|
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||||
|
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||||
|
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||||
|
export function requestOpenCommandBar(): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||||
* connection pool. */
|
* connection pool. */
|
||||||
@@ -49,6 +61,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
|||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
|
|
||||||
|
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||||
|
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||||
|
* through the same confirm-and-blockers flow the card's ✕ does
|
||||||
|
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||||
|
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||||
|
|
||||||
|
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||||
|
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||||
|
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||||
|
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||||
|
|
||||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||||
* hash navigation, but a split/drag can move focus without ever touching
|
* hash navigation, but a split/drag can move focus without ever touching
|
||||||
* `location.hash` — so a click back onto a session whose hash still
|
* `location.hash` — so a click back onto a session whose hash still
|
||||||
@@ -90,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
|||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
function metaLine(s: Session, now: number): string {
|
||||||
const dead = s.status === "Dead";
|
const dead = s.status === "Dead";
|
||||||
|
// #162: known from its sidecar with nothing running behind it — a distinct
|
||||||
|
// fragment, since it never exited.
|
||||||
|
const dormant = s.status === "Dormant";
|
||||||
const blockers = sessionBlockers(s);
|
const blockers = sessionBlockers(s);
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
const held = activityHeldMs(s.activity_since, now);
|
||||||
@@ -104,6 +130,7 @@ function metaLine(s: Session, now: number): string {
|
|||||||
commandFragment(s.command),
|
commandFragment(s.command),
|
||||||
blockers ? blockedFragment(blockers) : "",
|
blockers ? blockedFragment(blockers) : "",
|
||||||
dead ? `<span class="card-flag">exited</span>` : "",
|
dead ? `<span class="card-flag">exited</span>` : "",
|
||||||
|
dormant ? `<span class="card-flag">dormant</span>` : "",
|
||||||
];
|
];
|
||||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||||
}
|
}
|
||||||
@@ -164,6 +191,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The row outlives the snapshot it was built from: a refresh that changes
|
||||||
|
// nothing structural patches it in place rather than replacing it, so the
|
||||||
|
// handlers below have to read the current session, not the one captured here.
|
||||||
|
let cur = node.session;
|
||||||
const s = node.session;
|
const s = node.session;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row = document.createElement("a");
|
const row = document.createElement("a");
|
||||||
@@ -197,40 +228,86 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||||
});
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void requestKill(s, name);
|
requestKill(cur, sessionName(cur));
|
||||||
});
|
});
|
||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||||
});
|
});
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openContextMenu(ev.clientX, ev.clientY, [
|
openContextMenu(ev.clientX, ev.clientY, [
|
||||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||||
{
|
{
|
||||||
label: "Spawn similar",
|
label: "Spawn similar",
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kill session",
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||||
},
|
},
|
||||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
leafPatches.set(row, (next) => {
|
||||||
|
cur = next;
|
||||||
|
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||||
|
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||||
|
row.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||||
|
);
|
||||||
|
const rail = row.querySelector(".activity-rail");
|
||||||
|
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||||
|
const meta = row.querySelector(".card-line2");
|
||||||
|
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||||
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||||
|
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||||
|
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||||
|
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
* Everything about the tree that a patch cannot fix up.
|
||||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
*
|
||||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||||
* a forced kill. Anything else surfaces as an alert.
|
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||||
|
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||||
|
* signature is unchanged are patched into the standing rows instead; anything
|
||||||
|
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||||
*/
|
*/
|
||||||
async function requestKill(s: Session, name: string): Promise<void> {
|
function treeSignature(node: TreeNode): string {
|
||||||
|
if (node.kind === "folder") {
|
||||||
|
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||||
|
if (node.name && collapsed.has(node.path)) return head;
|
||||||
|
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||||
|
}
|
||||||
|
const s = node.session;
|
||||||
|
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||||
|
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||||
|
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||||
|
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||||
|
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||||
|
*/
|
||||||
|
function requestKill(s: Session, name: string): void {
|
||||||
|
openKillConfirmDialog({
|
||||||
|
name,
|
||||||
|
blockers: sessionBlockers(s),
|
||||||
|
onKill: async () => {
|
||||||
try {
|
try {
|
||||||
await killSession(s.id);
|
await killSession(s.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -243,8 +320,10 @@ async function requestKill(s: Session, name: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
throw e;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,16 +381,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
|||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||||
|
// belonged to the inline substring filter, which the command bar replaced
|
||||||
|
// (#157) — the bar's own no-results state took over with it.
|
||||||
|
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "empty";
|
el.className = "empty";
|
||||||
if (filtering) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
|
||||||
<div class="empty-title">No matches</div>
|
|
||||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||||
<div class="empty-title">No sessions yet</div>
|
<div class="empty-title">No sessions yet</div>
|
||||||
@@ -342,6 +417,9 @@ export interface SidebarHandle {
|
|||||||
setActive(id: string | null): void;
|
setActive(id: string | null): void;
|
||||||
/** Apply the full open/focused highlight state. */
|
/** Apply the full open/focused highlight state. */
|
||||||
setHighlights(h: SidebarHighlights): void;
|
setHighlights(h: SidebarHighlights): void;
|
||||||
|
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
||||||
|
* with `null`. A preview is a view, not state, so this only paints. */
|
||||||
|
setPreview(sessionId: string | null): void;
|
||||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||||
focusList(): void;
|
focusList(): void;
|
||||||
/** Whether the session list currently holds DOM focus. */
|
/** Whether the session list currently holds DOM focus. */
|
||||||
@@ -364,6 +442,11 @@ export interface SidebarOptions {
|
|||||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||||
* design kit, which render the sidebar without a daemon. */
|
* design kit, which render the sidebar without a daemon. */
|
||||||
link?: DaemonLink;
|
link?: DaemonLink;
|
||||||
|
/** The highlight moved onto a session, or off every session (`null`): the
|
||||||
|
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||||
|
* `source` is there so a mouse sweep can be debounced differently from a
|
||||||
|
* keyboard step. */
|
||||||
|
onPreview?: (sessionId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||||
@@ -374,12 +457,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// renders with absolute paths until it lands.
|
// renders with absolute paths until it lands.
|
||||||
let homeDir: string | undefined;
|
let homeDir: string | undefined;
|
||||||
|
|
||||||
|
// The field never holds a query: it is the touch entry point to the command
|
||||||
|
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||||
|
// on-screen keyboard over a field nothing is typed into.
|
||||||
const searchHeader = document.createElement("header");
|
const searchHeader = document.createElement("header");
|
||||||
searchHeader.className = "sidebar-search";
|
searchHeader.className = "sidebar-search";
|
||||||
searchHeader.innerHTML = `
|
searchHeader.innerHTML = `
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
class="search"
|
class="search"
|
||||||
|
readonly
|
||||||
placeholder="Search sessions…"
|
placeholder="Search sessions…"
|
||||||
aria-label="Search sessions"
|
aria-label="Search sessions"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -488,14 +575,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
handle.addEventListener("pointerup", endDrag);
|
handle.addEventListener("pointerup", endDrag);
|
||||||
handle.addEventListener("pointercancel", endDrag);
|
handle.addEventListener("pointercancel", endDrag);
|
||||||
|
|
||||||
|
// Touch entry (#157): the field is a button that looks like a field. It
|
||||||
|
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||||
|
// to the bar's own input, which is also why it blurs first: leaving the
|
||||||
|
// focus here would make the bar impossible to close without reopening it.
|
||||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||||
let filter = "";
|
const openBar = () => {
|
||||||
search.addEventListener("input", () => {
|
search.value = "";
|
||||||
filter = search.value;
|
search.blur();
|
||||||
refresh();
|
requestOpenCommandBar();
|
||||||
|
};
|
||||||
|
search.addEventListener("focus", openBar);
|
||||||
|
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||||
|
// the pointer path opens the bar too; the blur above keeps the two from
|
||||||
|
// stacking two overlays.
|
||||||
|
search.addEventListener("pointerdown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openBar();
|
||||||
});
|
});
|
||||||
|
|
||||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||||
|
// The previewed row is painted alongside the open/focused tiers so a
|
||||||
|
// re-render can't drop it, but it is never part of `highlights`: a preview
|
||||||
|
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
||||||
|
let previewing: string | null = null;
|
||||||
const applyHighlights = () => {
|
const applyHighlights = () => {
|
||||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||||
const id = n.dataset.sessionId;
|
const id = n.dataset.sessionId;
|
||||||
@@ -504,6 +607,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const open = highlights.open.has(id);
|
const open = highlights.open.has(id);
|
||||||
n.classList.toggle("is-active", focused);
|
n.classList.toggle("is-active", focused);
|
||||||
n.classList.toggle("is-open", open && !focused);
|
n.classList.toggle("is-open", open && !focused);
|
||||||
|
n.classList.toggle("is-previewing", previewing === id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -528,6 +632,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sessionOf = (key: string | null): string | null =>
|
||||||
|
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
||||||
|
|
||||||
const moveNav = (delta: number) => {
|
const moveNav = (delta: number) => {
|
||||||
const rows = navRows();
|
const rows = navRows();
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
@@ -539,6 +646,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
navKey = next.dataset.navKey ?? null;
|
navKey = next.dataset.navKey ?? null;
|
||||||
applyNav();
|
applyNav();
|
||||||
next.scrollIntoView({ block: "nearest" });
|
next.scrollIntoView({ block: "nearest" });
|
||||||
|
opts.onPreview?.(sessionOf(navKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||||
@@ -563,32 +671,96 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||||
|
// out: there is no hover there, and a tap already opens the session.
|
||||||
|
let hovered: string | null = null;
|
||||||
|
const setHovered = (id: string | null) => {
|
||||||
|
if (hovered === id) return;
|
||||||
|
hovered = id;
|
||||||
|
opts.onPreview?.(id);
|
||||||
|
};
|
||||||
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
|
if (e.pointerType === "touch") return;
|
||||||
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
|
});
|
||||||
|
treeHost.addEventListener("pointerleave", (e) => {
|
||||||
|
if (e.pointerType === "touch") return;
|
||||||
|
setHovered(null);
|
||||||
|
});
|
||||||
|
|
||||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||||
// keyboard stays here while j/k runs.
|
// keyboard stays here while j/k runs.
|
||||||
treeHost.tabIndex = -1;
|
treeHost.tabIndex = -1;
|
||||||
treeHost.addEventListener("keydown", (e) => {
|
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||||
// The inline rename field lives inside a row and owns its own keys.
|
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||||
if (isTypingTarget(e.target)) return;
|
|
||||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||||
else if (e.key === "Enter") activateNav();
|
const row = rowFor(navKey);
|
||||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
const id = row?.dataset.sessionId;
|
||||||
else return;
|
if (!row || !id) return undefined;
|
||||||
e.preventDefault();
|
const session = store.snapshot().sessions.get(id);
|
||||||
});
|
return session ? { row, session } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||||
|
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||||
|
* the key does nothing rather than toggling some other folder. */
|
||||||
|
const toggleHighlightedFolder = () => {
|
||||||
|
const row = rowFor(navKey);
|
||||||
|
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||||
|
row.click();
|
||||||
|
applyNav();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The session list's half of the command registry (#148). One set per
|
||||||
|
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
||||||
|
// labels and chords still come from the one registry.
|
||||||
|
const listKeys = new CommandSet(
|
||||||
|
defineCommands({
|
||||||
|
"list.next": () => moveNav(1),
|
||||||
|
"list.prev": () => moveNav(-1),
|
||||||
|
"list.activate": () => activateNav(),
|
||||||
|
"list.toggleFolder": () => toggleHighlightedFolder(),
|
||||||
|
"list.rename": () => {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) beginRename(hit.row, hit.session);
|
||||||
|
},
|
||||||
|
"list.kill": () => {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) requestKill(hit.session, sessionName(hit.session));
|
||||||
|
},
|
||||||
|
"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`. */
|
||||||
|
let renderedSignature: string | null = null;
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
// Rebuilding the tree now would blow away the field being typed into.
|
// Rebuilding the tree now would blow away the field being typed into.
|
||||||
if (renamingId !== null) return;
|
if (renamingId !== null) return;
|
||||||
const state = store.snapshot();
|
const state = store.snapshot();
|
||||||
const sessions = [...state.sessions.values()];
|
const sessions = [...state.sessions.values()];
|
||||||
const tree = buildTree(sessions, homeDir, filter);
|
const tree = buildTree(sessions, homeDir);
|
||||||
|
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||||
|
if (signature === renderedSignature) {
|
||||||
|
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||||
|
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||||
|
if (s) leafPatches.get(row)?.(s);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderedSignature = signature;
|
||||||
treeHost.replaceChildren();
|
treeHost.replaceChildren();
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
treeHost.appendChild(
|
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tree.kind === "folder") {
|
if (tree.kind === "folder") {
|
||||||
@@ -607,9 +779,49 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
/* absolute paths are a fine fallback */
|
/* absolute paths are a fine fallback */
|
||||||
});
|
});
|
||||||
|
|
||||||
const rerenderHandler = () => refresh();
|
// Dispatched when something outside the store changed what the tree should
|
||||||
|
// look like — a folder collapsed, a rename ended — none of which the store
|
||||||
|
// snapshot can show, so the signature check has to be bypassed.
|
||||||
|
const rerenderHandler = () => {
|
||||||
|
renderedSignature = null;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
|
// The command bar's action page (#151) picks an action and hands it here:
|
||||||
|
// both flows are the ones the card already has, reached by session rather
|
||||||
|
// than by row.
|
||||||
|
const killHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
requestKill(s, sessionName(s));
|
||||||
|
};
|
||||||
|
const renameHandler = (ev: Event) => {
|
||||||
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
const row = rowForSession(s);
|
||||||
|
if (row) beginRename(row, s);
|
||||||
|
};
|
||||||
|
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
|
|
||||||
|
/** The card a session is rendered as, expanding whatever folder hides it
|
||||||
|
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||||
|
* would otherwise swallow the request. */
|
||||||
|
function rowForSession(s: Session): HTMLElement | null {
|
||||||
|
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||||
|
const found = find();
|
||||||
|
if (found) return found;
|
||||||
|
// Folder paths are the abbreviated directory's segments joined back up
|
||||||
|
// (see buildTree), so the session's own path is normalised the same way
|
||||||
|
// before it is compared against them.
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
|
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||||
|
for (const folder of [...collapsed]) {
|
||||||
|
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||||
|
}
|
||||||
|
rerenderHandler();
|
||||||
|
return find();
|
||||||
|
}
|
||||||
|
|
||||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||||
|
|
||||||
@@ -635,6 +847,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
highlights = h;
|
highlights = h;
|
||||||
applyHighlights();
|
applyHighlights();
|
||||||
},
|
},
|
||||||
|
setPreview(sessionId: string | null) {
|
||||||
|
previewing = sessionId;
|
||||||
|
applyHighlights();
|
||||||
|
},
|
||||||
focusList() {
|
focusList() {
|
||||||
// Start from the session on screen — that is where the user's attention
|
// Start from the session on screen — that is where the user's attention
|
||||||
// already is — falling back to the standing highlight, then the top row.
|
// already is — falling back to the standing highlight, then the top row.
|
||||||
@@ -655,6 +871,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// refusing to refresh.
|
// refusing to refresh.
|
||||||
renamingId = null;
|
renamingId = null;
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||||
|
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: ID,
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||||
|
* harness in catalog.test.ts. */
|
||||||
|
function liveStore(initial: Session[]) {
|
||||||
|
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||||
|
const listeners = new Set<CatalogListener>();
|
||||||
|
const store = {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
listeners.add(fn);
|
||||||
|
fn(state);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
const push = (sessions: Session[]) => {
|
||||||
|
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||||
|
for (const fn of listeners) fn(state);
|
||||||
|
};
|
||||||
|
return { store, push };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||||
|
|
||||||
|
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||||
|
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||||
|
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ activity: "AwaitingInput" })]);
|
||||||
|
|
||||||
|
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||||
|
// the row's transitions, which is the flicker this avoids.
|
||||||
|
expect(rowOf(el)).toBe(before);
|
||||||
|
// …and the card still tells the truth about the session.
|
||||||
|
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||||
|
"awaiting-input",
|
||||||
|
);
|
||||||
|
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||||
|
"awaiting-input",
|
||||||
|
);
|
||||||
|
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when the row set changes", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||||
|
|
||||||
|
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when a session dies", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ status: "Dead" })]);
|
||||||
|
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
expect(rowOf(el).dataset.status).toBe("dead");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when a session is renamed", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ metadata: { name: "harmony" } })]);
|
||||||
|
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
|
import {
|
||||||
|
barGroups,
|
||||||
|
barRows,
|
||||||
|
closeCommandBar,
|
||||||
|
commandBarCandidates,
|
||||||
|
dedupeCommands,
|
||||||
|
directoryBarCandidates,
|
||||||
|
directoryCandidate,
|
||||||
|
isCommandBarOpen,
|
||||||
|
openCommandBar,
|
||||||
|
orderSessionsForBar,
|
||||||
|
parseBarQuery,
|
||||||
|
sessionBarCandidates,
|
||||||
|
sessionActionCandidates,
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
|
||||||
|
// way the pane went away.
|
||||||
|
it("sorts dormant sessions after live ones, like dead ones", () => {
|
||||||
|
const dormantRecent = session("dormant", {
|
||||||
|
status: "Dormant",
|
||||||
|
last_focused_at: "2026-06-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||||
|
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
|
||||||
|
"live",
|
||||||
|
"dormant",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the session action page (#151)", () => {
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||||
|
return openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerText(): string | undefined {
|
||||||
|
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||||
|
expect(activeName()).toBe("Kill session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the session being acted on", () => {
|
||||||
|
open();
|
||||||
|
press("ArrowDown");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the kind headers, since the page is one kind", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays fuzzy-searchable over the three actions", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
type("spwn");
|
||||||
|
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||||
|
type("zzz");
|
||||||
|
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a command row", () => {
|
||||||
|
openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||||
|
onOpenSession: () => {},
|
||||||
|
});
|
||||||
|
type(">");
|
||||||
|
const before = rowNames();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(before);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a directory row", async () => {
|
||||||
|
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||||
|
type("~");
|
||||||
|
await Promise.resolve();
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
expect(headerText()).toContain("worker");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(true);
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on backspace at an empty query", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type("");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toBeUndefined();
|
||||||
|
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps backspace ordinary while the page's query has text", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type("ren");
|
||||||
|
press("Backspace");
|
||||||
|
expect(headerText()).toContain("api");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the bar when the page's Escape has already been used", () => {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
press("Escape");
|
||||||
|
press("Escape");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (const [hook, key] of [
|
||||||
|
["onKillSession", "Kill session"],
|
||||||
|
["onRenameSession", "Rename session"],
|
||||||
|
["onSpawnSimilar", "Spawn similar"],
|
||||||
|
] as const) {
|
||||||
|
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
type(key);
|
||||||
|
press("Enter");
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([
|
||||||
|
"onKillSession:worker",
|
||||||
|
"onRenameSession:worker",
|
||||||
|
"onSpawnSimilar:worker",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands each action to the flow it already has, by default", () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const listeners: Array<[string, EventListener]> = [
|
||||||
|
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||||
|
];
|
||||||
|
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||||
|
try {
|
||||||
|
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||||
|
open();
|
||||||
|
press("Tab");
|
||||||
|
type(action);
|
||||||
|
press("Enter");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no action beyond the three", () => {
|
||||||
|
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||||
|
"kill",
|
||||||
|
"rename",
|
||||||
|
"spawn-similar",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
// 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. `tab` on a session drills into
|
||||||
|
// its action page (#151), which is the same overlay over three actions.
|
||||||
|
|
||||||
|
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||||
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_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 };
|
||||||
|
|
||||||
|
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||||
|
* single `label` field, ranked exactly like a registry command — and carries
|
||||||
|
* the action it stands for instead of a registry entry. */
|
||||||
|
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||||
|
|
||||||
|
export type BarCandidate =
|
||||||
|
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the session action page ----
|
||||||
|
|
||||||
|
/** The actions a session's action page offers, and no others: the bar picks
|
||||||
|
* one and hands off to the flow that already implements it
|
||||||
|
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||||
|
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||||
|
|
||||||
|
export interface SessionAction {
|
||||||
|
id: SessionActionId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||||
|
{ id: "kill", label: "Kill session" },
|
||||||
|
{ id: "rename", label: "Rename session" },
|
||||||
|
{ id: "spawn-similar", label: "Spawn similar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The action page's candidates, in the order above — which is the order an
|
||||||
|
* empty query lists them in, since the matcher keeps input order. */
|
||||||
|
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||||
|
return SESSION_ACTIONS.map((action) => ({
|
||||||
|
kind: "command",
|
||||||
|
action,
|
||||||
|
fields: { label: action.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||||
|
* `command` kind, so what separates them is what they carry. */
|
||||||
|
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||||
|
return candidate.kind === "command" && "action" in candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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) => {
|
||||||
|
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
||||||
|
// — sorts after the sessions you could actually attach to.
|
||||||
|
const deadA = a.status === "Running" ? 0 : 1;
|
||||||
|
const deadB = b.status === "Running" ? 0 : 1;
|
||||||
|
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 !== "Running") row.classList.add("is-dead");
|
||||||
|
} else if (isActionCandidate(candidate)) {
|
||||||
|
row.dataset.actionId = candidate.action.id;
|
||||||
|
} 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" && !isActionCandidate(candidate)) {
|
||||||
|
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;
|
||||||
|
/** The action page's three actions (#151). Each defaults to raising the
|
||||||
|
* event the flow that already implements it listens on — the sidebar's
|
||||||
|
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||||
|
* seeded from the session. The bar picks the action and nothing more. */
|
||||||
|
onKillSession?: (session: Session) => void;
|
||||||
|
onRenameSession?: (session: Session) => void;
|
||||||
|
onSpawnSimilar?: (session: Session) => 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 sessionEvent =
|
||||||
|
(name: string) =>
|
||||||
|
(session: Session): void => {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||||
|
};
|
||||||
|
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||||
|
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||||
|
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||||
|
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;
|
||||||
|
|
||||||
|
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||||
|
// the result-list query it was drilled into from — backing out restores it,
|
||||||
|
// so the round trip costs the operator nothing.
|
||||||
|
let page: Session | null = null;
|
||||||
|
let stashed = "";
|
||||||
|
let header: HTMLElement | null = null;
|
||||||
|
|
||||||
|
// `~`'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[] {
|
||||||
|
if (page) return sessionActionCandidates();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line naming the session being acted on, so the target is never
|
||||||
|
* ambiguous. Present only while the action page is up. */
|
||||||
|
function paintHeader(): void {
|
||||||
|
header?.remove();
|
||||||
|
header = null;
|
||||||
|
if (!page) return;
|
||||||
|
header = document.createElement("div");
|
||||||
|
header.className = "cmdbar-page-header";
|
||||||
|
header.setAttribute("role", "presentation");
|
||||||
|
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||||
|
panel.insertBefore(header, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
// On the action page the query line is over the three actions, so there is
|
||||||
|
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||||
|
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||||
|
input.placeholder = page
|
||||||
|
? `Act on ${sessionName(page)}…`
|
||||||
|
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||||
|
paintHeader();
|
||||||
|
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 — and the action page is one kind too.
|
||||||
|
if (parsed.sigil === null && !page) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||||
|
* other kind, and none on the page itself, which has no session rows. */
|
||||||
|
function drillIn(): void {
|
||||||
|
const candidate = rows[highlighted]?.candidate;
|
||||||
|
if (page || candidate?.kind !== "session") return;
|
||||||
|
page = candidate.session;
|
||||||
|
stashed = input.value;
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape, or backspace at an empty query: back to the result list with the
|
||||||
|
* query the drill-in was made from. */
|
||||||
|
function leavePage(): void {
|
||||||
|
page = null;
|
||||||
|
input.value = stashed;
|
||||||
|
stashed = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: SessionAction, session: Session): void {
|
||||||
|
close();
|
||||||
|
switch (action.id) {
|
||||||
|
case "kill":
|
||||||
|
killSession(session);
|
||||||
|
return;
|
||||||
|
case "rename":
|
||||||
|
renameSession(session);
|
||||||
|
return;
|
||||||
|
case "spawn-similar":
|
||||||
|
spawnSimilar(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(index: number): void {
|
||||||
|
const result = rows[index];
|
||||||
|
if (!result) return;
|
||||||
|
const candidate = result.candidate;
|
||||||
|
if (isActionCandidate(candidate)) {
|
||||||
|
// The page cannot be up without a session to act on.
|
||||||
|
if (page) runAction(candidate.action, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
// On an action page Escape is one level up, not the way out.
|
||||||
|
if (page) leavePage();
|
||||||
|
else close();
|
||||||
|
return;
|
||||||
|
case "Tab":
|
||||||
|
e.preventDefault();
|
||||||
|
drillIn();
|
||||||
|
return;
|
||||||
|
case "Backspace":
|
||||||
|
// Ordinary editing, except at an empty query on the action page.
|
||||||
|
if (page && input.value === "") {
|
||||||
|
e.preventDefault();
|
||||||
|
leavePage();
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COMMAND_SPECS,
|
||||||
|
CommandSet,
|
||||||
|
commandSpecs,
|
||||||
|
DEFAULT_LEADER,
|
||||||
|
canonicalizeKey,
|
||||||
|
controlByte,
|
||||||
|
defineCommands,
|
||||||
|
isBarTypingTarget,
|
||||||
|
isTerminalTextarea,
|
||||||
|
isTypingTarget,
|
||||||
|
keyCaps,
|
||||||
|
publishedCommands,
|
||||||
|
type Command,
|
||||||
|
} 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
||||||
|
// behind it is built from it, so a rebound leader carries its escape hatches
|
||||||
|
// with it instead of leaving them on a stale ctrl-a.
|
||||||
|
it("builds the chords behind it from the configured leader", () => {
|
||||||
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||||
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
|
// Commands that do not hang off the leader are untouched.
|
||||||
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches a configured leader's chord and not the default's", () => {
|
||||||
|
const literalCtrlK = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
||||||
|
leader: "ctrl-b",
|
||||||
|
});
|
||||||
|
set.handle(key("b", { ctrlKey: true }));
|
||||||
|
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The old leader is an ordinary key again: it holds nothing, and the chord
|
||||||
|
// that used to follow it runs nothing.
|
||||||
|
set.handle(key("a", { ctrlKey: true }));
|
||||||
|
set.handle(key("k", { ctrlKey: true }));
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the configured leader's own byte for the doubled chord", () => {
|
||||||
|
expect(controlByte("ctrl-b")).toBe("\x02");
|
||||||
|
});
|
||||||
|
|
||||||
|
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`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leader pressed twice (#158)", () => {
|
||||||
|
it("sends one literal leader byte and clears the pending chord", () => {
|
||||||
|
const literal = vi.fn();
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(
|
||||||
|
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
|
||||||
|
{ leader: DEFAULT_LEADER },
|
||||||
|
);
|
||||||
|
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The chord is cleared, not still held: the next key is ordinary again.
|
||||||
|
expect(set.handle(key("n"))).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the escape hatch behind the default leader", () => {
|
||||||
|
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
|
||||||
|
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not just the default: the chord is resolved from whatever leader this
|
||||||
|
// set was constructed with, so a rebound leader carries the escape hatch
|
||||||
|
// with it — the same proof #156 wrote for the TUI. The web has no config
|
||||||
|
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
|
||||||
|
it("follows a rebound leader, not just the default", () => {
|
||||||
|
const literal = vi.fn();
|
||||||
|
const rebound: Command = {
|
||||||
|
id: "pane.literalLeader",
|
||||||
|
label: "Send a literal leader key to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
scope: "global",
|
||||||
|
keys: ["ctrl-b ctrl-b"],
|
||||||
|
run: literal,
|
||||||
|
};
|
||||||
|
const set = new CommandSet([rebound], { leader: "ctrl-b" });
|
||||||
|
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The old leader no longer does anything special.
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("controlByte", () => {
|
||||||
|
it("computes the raw control byte from a plain ctrl chord", () => {
|
||||||
|
expect(controlByte("ctrl-a")).toBe("\x01");
|
||||||
|
expect(controlByte("ctrl-k")).toBe("\x0b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything that isn't a single plain ctrl chord", () => {
|
||||||
|
expect(() => controlByte("ctrl-shift-a")).toThrow();
|
||||||
|
expect(() => controlByte("a")).toThrow();
|
||||||
|
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||||
|
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `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, for one leader. The single list: labels,
|
||||||
|
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||||
|
* built from it (#159), so the configured leader — served on `/api/info` as
|
||||||
|
* `leader` — is what the overlay renders and the sets dispatch. */
|
||||||
|
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
||||||
|
return SPECS.map(({ leaderKeys, ...spec }) =>
|
||||||
|
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
||||||
|
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
||||||
|
|
||||||
|
const SPECS: readonly SpecSource[] = [
|
||||||
|
{
|
||||||
|
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`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane.literalLeader",
|
||||||
|
label: "Send a literal leader key to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The command list under the default leader. Iteration order everywhere else
|
||||||
|
* (the help overlay, the bar) comes from here. */
|
||||||
|
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
|
||||||
|
|
||||||
|
/** 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. `leader` picks
|
||||||
|
* the chords behind the leader; omit it for a set that has none. */
|
||||||
|
export function defineCommands(
|
||||||
|
handlers: Record<string, () => void>,
|
||||||
|
leader: string = DEFAULT_LEADER,
|
||||||
|
): Command[] {
|
||||||
|
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
||||||
|
return Object.entries(handlers).map(([id, run]) => {
|
||||||
|
const spec = byId.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
|
||||||
|
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
|
||||||
|
* itself so a different leader sends its own byte, not a hardcoded one. */
|
||||||
|
export function controlByte(chord: string): string {
|
||||||
|
const m = /^ctrl-([a-z])$/.exec(chord);
|
||||||
|
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
|
||||||
|
return String.fromCharCode(m[1].charCodeAt(0) - 96);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +3,23 @@ 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();
|
||||||
});
|
});
|
||||||
@@ -85,3 +102,26 @@ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+21
-5
@@ -74,7 +74,7 @@ export class ConnectionPool {
|
|||||||
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
||||||
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
||||||
*/
|
*/
|
||||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
|
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||||
let conn = this.cache.get(id);
|
let conn = this.cache.get(id);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
this.cache.delete(id);
|
this.cache.delete(id);
|
||||||
@@ -93,9 +93,9 @@ export class ConnectionPool {
|
|||||||
}
|
}
|
||||||
if (conn.el.parentElement !== pane) {
|
if (conn.el.parentElement !== pane) {
|
||||||
pane.replaceChildren(conn.el);
|
pane.replaceChildren(conn.el);
|
||||||
conn.onMount();
|
conn.onMount(opts);
|
||||||
} else if (!this.mounted.has(id)) {
|
} else if (!this.mounted.has(id)) {
|
||||||
conn.onMount();
|
conn.onMount(opts);
|
||||||
}
|
}
|
||||||
this.mounted.add(id);
|
this.mounted.add(id);
|
||||||
this.evict();
|
this.evict();
|
||||||
@@ -114,13 +114,17 @@ export class ConnectionPool {
|
|||||||
/**
|
/**
|
||||||
* Reconcile the visible set to exactly `entries` (in render order): park any
|
* Reconcile the visible set to exactly `entries` (in render order): park any
|
||||||
* mounted session not listed, mount/refresh each listed one in its pane.
|
* mounted session not listed, mount/refresh each listed one in its pane.
|
||||||
|
* `focus: false` shows the panes without taking the keyboard (preview).
|
||||||
*/
|
*/
|
||||||
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
|
setVisible(
|
||||||
|
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
|
||||||
|
opts?: { focus?: boolean },
|
||||||
|
): void {
|
||||||
const next = new Set(entries.map((e) => e.id));
|
const next = new Set(entries.map((e) => e.id));
|
||||||
for (const id of [...this.mounted]) {
|
for (const id of [...this.mounted]) {
|
||||||
if (!next.has(id)) this.unmount(id);
|
if (!next.has(id)) this.unmount(id);
|
||||||
}
|
}
|
||||||
for (const e of entries) this.mount(e.id, e.pane, e.info);
|
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
||||||
@@ -155,6 +159,18 @@ 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);
|
||||||
|
|||||||
+5
-6
@@ -2,6 +2,8 @@
|
|||||||
// 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";
|
||||||
|
|
||||||
@@ -31,12 +33,9 @@ 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) => {
|
window.addEventListener("keydown", (e) => hudKeys.handle(e));
|
||||||
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleHud();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-30
@@ -1,14 +1,8 @@
|
|||||||
// @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 {
|
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
|
||||||
SHORTCUTS,
|
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
||||||
closeHelp,
|
|
||||||
installHelpKeybind,
|
|
||||||
isHelpOpen,
|
|
||||||
isTypingTarget,
|
|
||||||
openHelp,
|
|
||||||
} from "./help.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installHelpKeybind();
|
installHelpKeybind();
|
||||||
@@ -30,14 +24,51 @@ function rows(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("help overlay", () => {
|
describe("help overlay", () => {
|
||||||
it("renders every registered shortcut, grouped", () => {
|
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||||
openHelp();
|
openHelp();
|
||||||
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
|
const groups = groupedCommands();
|
||||||
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
|
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||||
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
|
groups.map((g) => g.title),
|
||||||
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// #159: the leader comes from the daemon, so the overlay must render the
|
||||||
|
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
|
||||||
|
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
|
||||||
|
const bound = defineCommands(
|
||||||
|
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
|
||||||
|
"ctrl-b",
|
||||||
|
);
|
||||||
|
publishCommands(bound);
|
||||||
|
try {
|
||||||
|
openHelp();
|
||||||
|
const keysOf = (id: string) =>
|
||||||
|
[
|
||||||
|
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
|
||||||
|
.querySelectorAll("kbd"),
|
||||||
|
]
|
||||||
|
.map((k) => k.textContent)
|
||||||
|
.join(" ");
|
||||||
|
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
|
||||||
|
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
|
||||||
|
} finally {
|
||||||
|
// Leave the registry as the rest of the suite expects it.
|
||||||
|
publishCommands(
|
||||||
|
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens on ? and closes on a second ?", () => {
|
it("opens on ? and closes on a second ?", () => {
|
||||||
@@ -94,19 +125,3 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+37
-76
@@ -1,66 +1,15 @@
|
|||||||
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
||||||
* overlay. One registry drives both the rendering and what we claim is bound,
|
* overlay. It renders the command registry (`commands.ts`) — the same list
|
||||||
* so a binding added to `main.ts` without an entry here reads as missing. */
|
* that dispatches the keys — so a binding can never be documented as one
|
||||||
|
* thing and do another. */
|
||||||
|
|
||||||
export interface Shortcut {
|
import {
|
||||||
/** Keycaps, rendered left to right as separate <kbd>. */
|
CommandSet,
|
||||||
keys: string[];
|
defineCommands,
|
||||||
description: string;
|
keyCaps,
|
||||||
}
|
publishedCommands,
|
||||||
|
type Command,
|
||||||
export interface ShortcutGroup {
|
} from "./commands.js";
|
||||||
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: ["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;
|
||||||
|
|
||||||
@@ -105,7 +54,7 @@ export function openHelp(): HTMLElement {
|
|||||||
head.append(title, close);
|
head.append(title, close);
|
||||||
panel.appendChild(head);
|
panel.appendChild(head);
|
||||||
|
|
||||||
for (const group of SHORTCUTS) {
|
for (const group of groupedCommands()) {
|
||||||
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");
|
||||||
@@ -113,19 +62,20 @@ 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 item of group.items) {
|
for (const cmd 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 item.keys) {
|
for (const key of keyCaps(cmd.keys[0])) {
|
||||||
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 = item.description;
|
desc.textContent = cmd.label;
|
||||||
row.append(keys, desc);
|
row.append(keys, desc);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -150,6 +100,26 @@ 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
|
||||||
@@ -158,16 +128,7 @@ export function installHelpKeybind(): void {
|
|||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (open && overlayKeys.handle(e)) return;
|
||||||
e.preventDefault();
|
helpKeys.handle(e);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-22
@@ -1,14 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Forced-kill prompt (#81).
|
* The two kill prompts (#81, #160).
|
||||||
*
|
*
|
||||||
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
* A kill is destructive and unattended agents live in these sessions, so both
|
||||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
* clients ask before one goes through — the TUI's `KillConfirm`, and
|
||||||
* refusal gets its own dialog: it names what is in the way and offers the
|
* `openKillConfirmDialog` here (#160). Where the session's worktree still
|
||||||
* override the daemon's `force=true` already implements. A kill the daemon
|
* holds uncommitted or unpushed work, the confirmation says so up front, the
|
||||||
* accepts never reaches here — there is nothing to warn about.
|
* same way the TUI's does.
|
||||||
|
*
|
||||||
|
* The daemon decides the rest: it refuses a kill over such a worktree, and
|
||||||
|
* that refusal needs an answer the confirmation cannot give — a forced kill.
|
||||||
|
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
|
||||||
|
* reaches it.
|
||||||
*/
|
*/
|
||||||
import { formatInTheWay, type InTheWay } from "./types.js";
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||||
|
|
||||||
|
/** What both prompts need: a target to name and a button that acts on it. */
|
||||||
|
interface KillDialogSpec {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** Sentence under the title. */
|
||||||
|
body: string;
|
||||||
|
/** Label on the acting button. */
|
||||||
|
confirmLabel: string;
|
||||||
|
/** Extra class on the acting button, so tests and styles can find it. */
|
||||||
|
confirmClass: string;
|
||||||
|
/** Run it. A rejection is rendered in the dialog, which stays open so the
|
||||||
|
* press can be retried. */
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillConfirmPrompt {
|
||||||
|
name: string;
|
||||||
|
/** What the card last saw in the way, when anything was. Shown before the
|
||||||
|
* attempt, so "this will be refused" is not a surprise. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Run the plain kill. Opening a further dialog from here is allowed: the
|
||||||
|
* confirmation only closes itself if it is still the one on screen. */
|
||||||
|
onKill: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForceKillPrompt {
|
export interface ForceKillPrompt {
|
||||||
/** Session name, as the sidebar shows it. */
|
/** Session name, as the sidebar shows it. */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
|
|||||||
open = null;
|
open = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
/** Mount one of the prompts. Opening while one is up replaces it, so the
|
||||||
* can never stack two dialogs. */
|
* confirmation and a refusal that follows it can never stack. */
|
||||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
function mount(p: KillDialogSpec): HTMLElement {
|
||||||
closeForceKillDialog();
|
closeForceKillDialog();
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
|
|
||||||
const body = document.createElement("p");
|
const body = document.createElement("p");
|
||||||
body.className = "kill-dialog-body";
|
body.className = "kill-dialog-body";
|
||||||
body.textContent = p.blockers
|
body.textContent = p.body;
|
||||||
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
|
||||||
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
|
||||||
|
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
error.className = "kill-dialog-error";
|
error.className = "kill-dialog-error";
|
||||||
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||||
cancel.textContent = "Cancel";
|
cancel.textContent = "Cancel";
|
||||||
cancel.addEventListener("click", closeForceKillDialog);
|
cancel.addEventListener("click", closeForceKillDialog);
|
||||||
const force = document.createElement("button");
|
const confirm = document.createElement("button");
|
||||||
force.type = "button";
|
confirm.type = "button";
|
||||||
force.className = "btn btn-danger kill-dialog-force";
|
confirm.className = `btn btn-danger ${p.confirmClass}`;
|
||||||
force.textContent = "Force kill";
|
confirm.textContent = p.confirmLabel;
|
||||||
force.addEventListener("click", () => {
|
confirm.addEventListener("click", () => {
|
||||||
force.disabled = true;
|
confirm.disabled = true;
|
||||||
void p
|
void p
|
||||||
.onForce()
|
.onConfirm()
|
||||||
.then(closeForceKillDialog)
|
.then(() => {
|
||||||
|
// A handler that opened the refusal prompt has already replaced this
|
||||||
|
// dialog; closing here would take that one down with it.
|
||||||
|
if (open?.el === el) closeForceKillDialog();
|
||||||
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
force.disabled = false;
|
if (open?.el !== el) return;
|
||||||
|
confirm.disabled = false;
|
||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
actions.append(cancel, force);
|
actions.append(cancel, confirm);
|
||||||
|
|
||||||
panel.append(title, body, error, actions);
|
panel.append(title, body, error, actions);
|
||||||
|
|
||||||
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeydown, true);
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
open = { el, onKeydown };
|
open = { el, onKeydown };
|
||||||
|
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
||||||
|
// away from whatever asked for the dialog.
|
||||||
cancel.focus();
|
cancel.focus();
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask before a kill the daemon has not seen yet (#160). */
|
||||||
|
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
|
||||||
|
: "The session and everything running in it go away. Its worktree stays on disk.",
|
||||||
|
confirmLabel: "Kill",
|
||||||
|
confirmClass: "kill-dialog-kill",
|
||||||
|
onConfirm: p.onKill,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer the override after the daemon refuses a guarded kill (#81). */
|
||||||
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
|
||||||
|
confirmLabel: "Force kill",
|
||||||
|
confirmClass: "kill-dialog-force",
|
||||||
|
onConfirm: p.onForce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
|
|||||||
"--muted",
|
"--muted",
|
||||||
"--faint",
|
"--faint",
|
||||||
"--accent",
|
"--accent",
|
||||||
|
"--preview",
|
||||||
"--danger",
|
"--danger",
|
||||||
"--warn",
|
"--warn",
|
||||||
];
|
];
|
||||||
|
|||||||
+192
-30
@@ -6,7 +6,9 @@ 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, isTypingTarget } from "./help.js";
|
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||||
|
import { CommandSet, DEFAULT_LEADER, controlByte, 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";
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
|
gitRoot,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,9 +32,11 @@ import { mountHud, type Hud } from "./hud.js";
|
|||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.js";
|
import { loadLayout, saveLayout } from "./persistence.js";
|
||||||
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
FOCUS_SIDEBAR_EVENT,
|
||||||
|
OPEN_COMMAND_BAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
SPAWN_SIMILAR_EVENT,
|
SPAWN_SIMILAR_EVENT,
|
||||||
@@ -78,6 +83,21 @@ 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;
|
||||||
|
// The leader is the operator's, not this module's (#159): a daemon that
|
||||||
|
// names one rebinds the chords hanging off it, and the `?` overlay and the
|
||||||
|
// command bar re-render from the rebuilt set.
|
||||||
|
if (info.leader) useLeader(info.leader);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* absolute paths and the default leader are fine fallbacks */
|
||||||
|
});
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -104,9 +124,20 @@ shellEl.dataset.route = "catalog";
|
|||||||
// after the welcome renderer). The hook only fires on user interaction, by which
|
// after the welcome renderer). The hook only fires on user interaction, by which
|
||||||
// point `mv` is assigned.
|
// point `mv` is assigned.
|
||||||
let mv: MultiView;
|
let mv: MultiView;
|
||||||
|
|
||||||
|
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
||||||
|
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
||||||
|
// costs a couple of pane mounts instead of one per row walked.
|
||||||
|
const schedulePreview = createPreviewSchedule((sessionId) => {
|
||||||
|
ensureMultiView();
|
||||||
|
mv?.previewSession(sessionId);
|
||||||
|
updateShellRoute();
|
||||||
|
});
|
||||||
|
|
||||||
const sidebar = renderSidebar(store, {
|
const sidebar = renderSidebar(store, {
|
||||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||||
link,
|
link,
|
||||||
|
onPreview: schedulePreview,
|
||||||
});
|
});
|
||||||
const content = document.createElement("section");
|
const content = document.createElement("section");
|
||||||
content.className = "content";
|
content.className = "content";
|
||||||
@@ -184,7 +215,9 @@ function infoFor(sessionId: string) {
|
|||||||
|
|
||||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||||
function updateShellRoute(): void {
|
function updateShellRoute(): void {
|
||||||
const hasPanes = openSessions(layout).size > 0;
|
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
|
||||||
|
// not swap the list out for a terminal on the way past a row.
|
||||||
|
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
|
||||||
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +277,18 @@ mv = createMultiView({
|
|||||||
onLayoutChange: scheduleSave,
|
onLayoutChange: scheduleSave,
|
||||||
});
|
});
|
||||||
let contentShowsMv = false;
|
let contentShowsMv = false;
|
||||||
function showMultiView(): void {
|
/** Put the multi-view in the content pane, without rendering it. A preview
|
||||||
|
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
|
||||||
|
* first re-mount the pane it is about to replace, and that mount takes the
|
||||||
|
* keyboard away from the list being walked. */
|
||||||
|
function ensureMultiView(): void {
|
||||||
if (!contentShowsMv) {
|
if (!contentShowsMv) {
|
||||||
content.replaceChildren(mv.el);
|
content.replaceChildren(mv.el);
|
||||||
contentShowsMv = true;
|
contentShowsMv = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
function showMultiView(): void {
|
||||||
|
ensureMultiView();
|
||||||
mv.render();
|
mv.render();
|
||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
}
|
}
|
||||||
@@ -305,6 +345,16 @@ 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[]),
|
||||||
@@ -312,13 +362,7 @@ async function requestFastForward(): Promise<void> {
|
|||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
const presets = await spawnPresets();
|
||||||
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) {
|
||||||
@@ -340,42 +384,159 @@ async function requestFastForward(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
||||||
// textarea, search) so an attached session's keys are never hijacked.
|
// project. A directory the project scan never saw stands in as its own project;
|
||||||
document.addEventListener("keydown", (e) => {
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||||
if (isTypingTarget(e.target)) return;
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||||
// The help overlay owns the keyboard while it is up.
|
const [discovered, name] = await Promise.all([
|
||||||
if (isHelpOpen()) return;
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
if (e.key === "n") {
|
fetchNames(1)
|
||||||
e.preventDefault();
|
.then((batch) => batch.names[0])
|
||||||
requestOpenSpawn();
|
.catch(() => undefined),
|
||||||
} else if (e.key === "N") {
|
]);
|
||||||
e.preventDefault();
|
const presets = await spawnPresets();
|
||||||
void requestFastForward();
|
const known = discovered.find((p) => p.path === path);
|
||||||
|
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) => {
|
//
|
||||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||||
if (e.key !== "H" && e.key !== "h") return;
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
e.preventDefault();
|
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
||||||
|
// (#149).
|
||||||
|
let currentLeader = DEFAULT_LEADER;
|
||||||
|
|
||||||
|
function buildFocusKeys(leader: string): CommandSet {
|
||||||
|
return new CommandSet(
|
||||||
|
defineCommands(
|
||||||
|
{
|
||||||
|
"focus.toggle": () => {
|
||||||
if (sidebar.hasListFocus()) {
|
if (sidebar.hasListFocus()) {
|
||||||
pool.focusTerminal();
|
pool.focusTerminal();
|
||||||
} else {
|
} else {
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
sidebar.focusList();
|
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"),
|
||||||
|
// The leader itself, pressed twice, tmux-style: sends one literal
|
||||||
|
// leader byte and clears the pending chord instead of resolving as a
|
||||||
|
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
||||||
|
// hardcoded byte, so a rebound leader sends its own.
|
||||||
|
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
||||||
|
},
|
||||||
|
leader,
|
||||||
|
),
|
||||||
|
{ leader },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
||||||
|
|
||||||
|
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
||||||
|
* same command ids replaces them, so the help overlay and the command bar —
|
||||||
|
* both of which render the published registry when they open — show the
|
||||||
|
* configured chord rather than the default. */
|
||||||
|
function useLeader(leader: string): void {
|
||||||
|
if (leader === currentLeader) return;
|
||||||
|
currentLeader = leader;
|
||||||
|
focusKeys = buildFocusKeys(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 openBar = () => {
|
||||||
|
// 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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
||||||
|
|
||||||
|
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
||||||
|
// phone, so the field the operator already reaches for opens the same bar.
|
||||||
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
||||||
|
|
||||||
|
// 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`.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||||
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||||
|
// being provisional rather than snapping back the moment the list blurs.
|
||||||
|
const previewed = mv.previewedSessionId();
|
||||||
|
if (previewed) focusSession(previewed);
|
||||||
|
pool.focusTerminal();
|
||||||
|
});
|
||||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
sidebar.focusList();
|
sidebar.focusList();
|
||||||
@@ -386,6 +547,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
|||||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||||
* changing, leaving a click back onto that session's row silently ignored. */
|
* changing, leaving a click back onto that session's row silently ignored. */
|
||||||
function focusSession(sessionId: string): void {
|
function focusSession(sessionId: string): void {
|
||||||
|
schedulePreview.cancel();
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openSession(sessionId);
|
mv.openSession(sessionId);
|
||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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]],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
|
|||||||
el: document.createElement("aside"),
|
el: document.createElement("aside"),
|
||||||
setActive() {},
|
setActive() {},
|
||||||
setHighlights() {},
|
setHighlights() {},
|
||||||
|
setPreview() {},
|
||||||
focusList() {},
|
focusList() {},
|
||||||
hasListFocus() {
|
hasListFocus() {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+193
-13
@@ -11,6 +11,7 @@ import {
|
|||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
|
freshId,
|
||||||
type Dir,
|
type Dir,
|
||||||
type Layout,
|
type Layout,
|
||||||
leaves,
|
leaves,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
openSessions,
|
openSessions,
|
||||||
removeLeaf,
|
removeLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
|
type Tab,
|
||||||
} from "./layout.js";
|
} from "./layout.js";
|
||||||
import type { AttachInfo } from "./attach.js";
|
import type { AttachInfo } from "./attach.js";
|
||||||
|
|
||||||
@@ -57,11 +59,22 @@ export interface MultiView {
|
|||||||
/** Rebuild the view from the current layout. */
|
/** Rebuild the view from the current layout. */
|
||||||
render: () => void;
|
render: () => void;
|
||||||
focusedSessionId: () => string | null;
|
focusedSessionId: () => string | null;
|
||||||
|
/** Show `sessionId` in the content pane without committing to it
|
||||||
|
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
|
||||||
|
* tab carries it. `null` puts back whatever was on screen before. */
|
||||||
|
previewSession: (sessionId: string | null) => void;
|
||||||
|
/** The session being previewed, or null. */
|
||||||
|
previewedSessionId: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMultiView(opts: MultiViewOptions): MultiView {
|
export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||||
const persist = () => opts.onLayoutChange?.();
|
// A preview must never reach localStorage: it is undone the moment the
|
||||||
|
// highlight moves on, and the layout it would save is not the operator's.
|
||||||
|
const persist = () => {
|
||||||
|
if (preview) return;
|
||||||
|
opts.onLayoutChange?.();
|
||||||
|
};
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "mv";
|
el.className = "mv";
|
||||||
@@ -83,8 +96,119 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- preview (docs/PREVIEW.md) ----
|
||||||
|
// A preview is a temporary *view* change, never a layout change: it records
|
||||||
|
// exactly what it moved so ending it puts the layout back byte for byte, and
|
||||||
|
// it neither persists nor reports pane focus to the daemon while it is up.
|
||||||
|
interface Preview {
|
||||||
|
sessionId: string;
|
||||||
|
/** Tab that was active before the preview. */
|
||||||
|
prevActiveTabId: string;
|
||||||
|
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||||
|
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||||
|
/** Throwaway tab built for a session that no pane was showing. */
|
||||||
|
tempTabId: string | null;
|
||||||
|
}
|
||||||
|
let preview: Preview | null = null;
|
||||||
|
/// Set while a preview-driven render runs: ending a preview re-mounts the
|
||||||
|
/// pane that was there, and that mount must not pull the keyboard out of the
|
||||||
|
/// sidebar the operator is still walking.
|
||||||
|
let quietRender = false;
|
||||||
|
|
||||||
|
/** Render without any mount taking the keyboard. */
|
||||||
|
function renderQuietly(): void {
|
||||||
|
quietRender = true;
|
||||||
|
try {
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
quietRender = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
|
||||||
|
|
||||||
|
/** Undo the preview's moves. Mutates only — the caller renders. */
|
||||||
|
function restorePreview(): boolean {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return false;
|
||||||
|
preview = null;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||||
|
if (i >= 0) layout.tabs.splice(i, 1);
|
||||||
|
} else if (p.prevFocus) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||||
|
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||||
|
}
|
||||||
|
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||||
|
? p.prevActiveTabId
|
||||||
|
: layout.tabs[0].id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep what the preview is showing: its throwaway tab becomes a real one,
|
||||||
|
* and the focus it moved stands. Returns true when `sessionId` was the
|
||||||
|
* session being previewed; any other preview is simply discarded. */
|
||||||
|
function settlePreview(sessionId: string): boolean {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return false;
|
||||||
|
if (p.sessionId !== sessionId) {
|
||||||
|
restorePreview();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
preview = null;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||||
|
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewSession(sessionId: string | null): void {
|
||||||
|
if (preview?.sessionId === sessionId) return;
|
||||||
|
if (sessionId === null) {
|
||||||
|
if (restorePreview()) renderQuietly();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restorePreview();
|
||||||
|
const prevActiveTabId = layout.activeTabId;
|
||||||
|
const loc = locateSession(layout, sessionId);
|
||||||
|
if (loc) {
|
||||||
|
preview = {
|
||||||
|
sessionId,
|
||||||
|
prevActiveTabId,
|
||||||
|
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||||
|
tempTabId: null,
|
||||||
|
};
|
||||||
|
layout.activeTabId = loc.tab.id;
|
||||||
|
loc.tab.focusedLeafId = loc.leaf.id;
|
||||||
|
} else {
|
||||||
|
// Not on screen anywhere: a throwaway tab carries it, so no existing
|
||||||
|
// pane is evicted and nothing the operator arranged moves.
|
||||||
|
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||||
|
layout.tabs.push(tab);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||||
|
}
|
||||||
|
renderQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any user action that changes the layout for real ends the preview first,
|
||||||
|
* so it never bakes a throwaway tab into the arrangement. */
|
||||||
|
function dropPreview(): void {
|
||||||
|
restorePreview();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- highlight helpers ----
|
// ---- highlight helpers ----
|
||||||
|
|
||||||
|
/** Sessions the operator actually has open — the preview's throwaway tab
|
||||||
|
* does not count, or hovering the sidebar would flip the shell's route. */
|
||||||
|
function realOpenSessions(): Set<string> {
|
||||||
|
const out = openSessions(layout);
|
||||||
|
if (preview?.tempTabId) out.delete(preview.sessionId);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function focusedLeafSession(): string | null {
|
function focusedLeafSession(): string | null {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||||
@@ -97,12 +221,23 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||||
|
// The previewed pane's titlebar wears the preview colour instead of the
|
||||||
|
// accent tint a focused pane gets (docs/PREVIEW.md).
|
||||||
|
pane.classList.toggle(
|
||||||
|
"is-preview",
|
||||||
|
preview !== null && pane.dataset.sessionId === preview.sessionId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
pool.setFocus(focusedLeafSession());
|
// A preview is not a focused pane: reporting it would clear the session's
|
||||||
|
// `AwaitingInput` glow just because the operator scrolled past it.
|
||||||
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: openSessions(layout),
|
open: realOpenSessions(),
|
||||||
focused: focusedLeafSession(),
|
focused: focusedLeafSession(),
|
||||||
});
|
});
|
||||||
|
// The row the preview came from wears the same colour, so the sidebar and
|
||||||
|
// the pane read as one thing rather than two unrelated highlights.
|
||||||
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusLeaf(leafId: string): void {
|
function focusLeaf(leafId: string): void {
|
||||||
@@ -168,6 +303,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
pane.addEventListener(
|
pane.addEventListener(
|
||||||
"pointerdown",
|
"pointerdown",
|
||||||
(e) => {
|
(e) => {
|
||||||
|
// Clicking into a previewed pane is the operator choosing it.
|
||||||
|
if (preview && leaf.sessionId === preview.sessionId) {
|
||||||
|
settlePreview(leaf.sessionId);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
focusLeaf(leaf.id);
|
focusLeaf(leaf.id);
|
||||||
// Drag a pane by its title bar (desktop only — no docking on mobile).
|
// Drag a pane by its title bar (desktop only — no docking on mobile).
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
@@ -252,24 +392,31 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
for (const t of layout.tabs) {
|
for (const t of layout.tabs) {
|
||||||
const tab = document.createElement("button");
|
const tab = document.createElement("button");
|
||||||
tab.type = "button";
|
tab.type = "button";
|
||||||
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
|
tab.className =
|
||||||
|
"mv-tab" +
|
||||||
|
(t.id === layout.activeTabId ? " is-active" : "") +
|
||||||
|
(isTempTab(t.id) ? " is-preview" : "");
|
||||||
const n = leaves(t.root).filter((l) => l.sessionId).length;
|
const n = leaves(t.root).filter((l) => l.sessionId).length;
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = t.title + (n ? ` · ${n}` : "");
|
label.textContent = t.title + (n ? ` · ${n}` : "");
|
||||||
tab.appendChild(label);
|
tab.appendChild(label);
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
|
if (isTempTab(t.id)) return; // a preview tab is not a place to go
|
||||||
|
dropPreview();
|
||||||
if (layout.activeTabId !== t.id) {
|
if (layout.activeTabId !== t.id) {
|
||||||
layout.activeTabId = t.id;
|
layout.activeTabId = t.id;
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
render();
|
||||||
});
|
});
|
||||||
if (layout.tabs.length > 1) {
|
// A preview tab is not the operator's to close: it goes on its own.
|
||||||
|
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
|
||||||
const close = document.createElement("span");
|
const close = document.createElement("span");
|
||||||
close.className = "mv-tab-close";
|
close.className = "mv-tab-close";
|
||||||
close.textContent = "✕";
|
close.textContent = "✕";
|
||||||
close.title = "Close tab";
|
close.title = "Close tab";
|
||||||
close.addEventListener("click", (e) => {
|
close.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
dropPreview();
|
||||||
closeTab(layout, t.id);
|
closeTab(layout, t.id);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -283,6 +430,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
add.textContent = "+";
|
add.textContent = "+";
|
||||||
add.title = "New tab";
|
add.title = "New tab";
|
||||||
add.addEventListener("click", () => {
|
add.addEventListener("click", () => {
|
||||||
|
dropPreview();
|
||||||
addTab(layout);
|
addTab(layout);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -292,12 +440,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// ---- mutations ----
|
// ---- mutations ----
|
||||||
|
|
||||||
function splitFocused(leafId: string, dir: Dir): void {
|
function splitFocused(leafId: string, dir: Dir): void {
|
||||||
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePane(leafId: string): void {
|
function closePane(leafId: string): void {
|
||||||
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
removeLeaf(tab, leafId);
|
removeLeaf(tab, leafId);
|
||||||
if (tab.focusedLeafId === leafId) {
|
if (tab.focusedLeafId === leafId) {
|
||||||
@@ -307,6 +457,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSession(sessionId: string): void {
|
function openSession(sessionId: string): void {
|
||||||
|
// Opening what is already previewed keeps exactly what is on screen: the
|
||||||
|
// throwaway tab becomes a real one instead of the session moving again.
|
||||||
|
if (settlePreview(sessionId)) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
layout.activeTabId = loc.tab.id;
|
layout.activeTabId = loc.tab.id;
|
||||||
@@ -337,6 +493,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
||||||
/// operator was reading (#100) — what the Spawn button asks for.
|
/// operator was reading (#100) — what the Spawn button asks for.
|
||||||
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
||||||
|
if (settlePreview(sessionId)) {
|
||||||
|
render();
|
||||||
|
opts.onOpenSessionsChange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
layout.activeTabId = loc.tab.id;
|
layout.activeTabId = loc.tab.id;
|
||||||
@@ -384,13 +545,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// A lone tab has nothing to switch between, so the bar is only a strip of
|
// A lone tab has nothing to switch between, so the bar is only a strip of
|
||||||
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
||||||
// own tab (#100), which brings the bar back with the "+" button on it.
|
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||||
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
// The preview's throwaway tab must not make the bar appear and vanish as
|
||||||
|
// the highlight moves, so it does not count towards "one tab only".
|
||||||
|
el.classList.toggle(
|
||||||
|
"mv-solo-tab",
|
||||||
|
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
|
||||||
|
);
|
||||||
|
el.classList.toggle("mv-previewing", preview !== null);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
if (!preview) pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
sidebar.setHighlights({ open: openSessions(layout), focused: null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
|
||||||
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -408,7 +576,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||||
canvas.appendChild(buildTree(tab.root, hosts));
|
canvas.appendChild(buildTree(tab.root, hosts));
|
||||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
// A preview keeps the keyboard in the sidebar: mounting must not focus the
|
||||||
|
// terminal, or j/k would stop walking the list after the first step.
|
||||||
|
pool.setVisible(
|
||||||
|
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
|
||||||
|
{ focus: !preview && !quietRender },
|
||||||
|
);
|
||||||
syncFocus();
|
syncFocus();
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
@@ -425,18 +598,22 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
const pane = buildPane(focused);
|
const pane = buildPane(focused);
|
||||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||||
|
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
|
||||||
wrap.appendChild(pane.el);
|
wrap.appendChild(pane.el);
|
||||||
canvas.appendChild(wrap);
|
canvas.appendChild(wrap);
|
||||||
|
|
||||||
// Only the focused leaf's session is live on mobile; the rest park.
|
// Only the focused leaf's session is live on mobile; the rest park.
|
||||||
pool.setVisible(
|
pool.setVisible(
|
||||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||||
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||||
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSession(sessionId: string): void {
|
function closeSession(sessionId: string): void {
|
||||||
|
dropPreview();
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
removeLeaf(loc.tab, loc.leaf.id);
|
removeLeaf(loc.tab, loc.leaf.id);
|
||||||
@@ -628,6 +805,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
||||||
|
dropPreview();
|
||||||
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,9 +814,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSession,
|
openSession,
|
||||||
openSessionBeside,
|
openSessionBeside,
|
||||||
closeSession,
|
closeSession,
|
||||||
openSessions: () => openSessions(layout),
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: focusedLeafSession,
|
focusedSessionId: focusedLeafSession,
|
||||||
|
previewSession,
|
||||||
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
|
||||||
|
// content pane without changing the layout the operator built.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
|
||||||
|
import { serializeLayout } from "./persistence.js";
|
||||||
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
|
import type { ConnectionPool } from "./connections.js";
|
||||||
|
import type { SidebarHandle } from "./catalog.js";
|
||||||
|
|
||||||
|
function stubFetch(): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const body = url.includes("/api/info")
|
||||||
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||||
|
: undefined;
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spy {
|
||||||
|
pool: ConnectionPool;
|
||||||
|
visible: () => string[];
|
||||||
|
focusCalls: (string | null)[];
|
||||||
|
mountedWithFocus: (boolean | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakePool(): Spy {
|
||||||
|
let visible: string[] = [];
|
||||||
|
const focusCalls: (string | null)[] = [];
|
||||||
|
const mountedWithFocus: (boolean | undefined)[] = [];
|
||||||
|
const pool = {
|
||||||
|
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
|
||||||
|
visible = entries.map((e) => e.id);
|
||||||
|
mountedWithFocus.push(opts?.focus);
|
||||||
|
},
|
||||||
|
setFocus(id: string | null) {
|
||||||
|
focusCalls.push(id);
|
||||||
|
},
|
||||||
|
} as unknown as ConnectionPool;
|
||||||
|
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||||
|
const previewRow: { id: string | null } = { id: null };
|
||||||
|
|
||||||
|
function fakeSidebar(): SidebarHandle {
|
||||||
|
return {
|
||||||
|
el: document.createElement("aside"),
|
||||||
|
setActive() {},
|
||||||
|
setHighlights() {},
|
||||||
|
setPreview(id: string | null) {
|
||||||
|
previewRow.id = id;
|
||||||
|
},
|
||||||
|
focusList() {},
|
||||||
|
hasListFocus() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
|
||||||
|
const mv = createMultiView({
|
||||||
|
pool,
|
||||||
|
layout,
|
||||||
|
sidebar: fakeSidebar(),
|
||||||
|
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||||
|
onDisconnect: () => {},
|
||||||
|
renderWelcome: () => document.createElement("div"),
|
||||||
|
onLayoutChange,
|
||||||
|
});
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
return mv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
|
||||||
|
function twoTabs(): Layout {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const first = layout.tabs[0];
|
||||||
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||||
|
const second = addTab(layout);
|
||||||
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||||
|
layout.activeTabId = second.id;
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar preview", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
previewRow.id = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings a session's own tab forward, then puts the layout back", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(spy.visible()).toEqual(["s1"]);
|
||||||
|
expect(mv.previewedSessionId()).toBe("s1");
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(spy.visible()).toEqual(["s2"]);
|
||||||
|
expect(mv.previewedSessionId()).toBeNull();
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(previewRow.id).toBe("s1");
|
||||||
|
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
|
||||||
|
).toBe("s1");
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(previewRow.id).toBeNull();
|
||||||
|
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(spy.visible()).toEqual(["s9"]);
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
// The throwaway tab is not something the operator has open.
|
||||||
|
expect(mv.openSessions().has("s9")).toBe(false);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepping between previews leaves only the newest one on screen", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
mv.previewSession("s1");
|
||||||
|
mv.previewSession("s8");
|
||||||
|
expect(spy.visible()).toEqual(["s8"]);
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
mv.openSession("s9");
|
||||||
|
|
||||||
|
expect(mv.previewedSessionId()).toBeNull();
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
expect(mv.openSessions().has("s9")).toBe(true);
|
||||||
|
expect(spy.visible()).toEqual(["s9"]);
|
||||||
|
// Nothing the operator had open was evicted to make room.
|
||||||
|
expect(mv.openSessions().has("s2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never persists, and saves again once the preview is settled", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const saves = vi.fn();
|
||||||
|
const mv = makeMv(layout, spy.pool, saves);
|
||||||
|
saves.mockClear();
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(saves).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(saves).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports a previewed pane as focused to the daemon", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
spy.focusCalls.length = 0;
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(spy.focusCalls).toEqual([]);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(spy.focusCalls).toEqual(["s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts a preview without taking the keyboard", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
spy.mountedWithFocus.length = 0;
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(spy.mountedWithFocus).toEqual([false]);
|
||||||
|
|
||||||
|
mv.openSession("s1");
|
||||||
|
expect(spy.mountedWithFocus.at(-1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tab bar hidden while a lone tab is previewing", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||||
|
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
|
||||||
|
|
||||||
|
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
|
||||||
|
function harness() {
|
||||||
|
let now = 1_000;
|
||||||
|
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||||
|
let nextHandle = 1;
|
||||||
|
const emitted: (string | null)[] = [];
|
||||||
|
|
||||||
|
const schedule = createPreviewSchedule((id) => emitted.push(id), {
|
||||||
|
now: () => now,
|
||||||
|
setTimer: (fn, ms) => {
|
||||||
|
const h = nextHandle++;
|
||||||
|
timers.set(h, { at: now + ms, fn });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Advance the clock, firing any timer that comes due. */
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
now += ms;
|
||||||
|
for (const [h, t] of [...timers]) {
|
||||||
|
if (t.at <= now) {
|
||||||
|
timers.delete(h);
|
||||||
|
t.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { schedule, emitted, advance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preview schedule", () => {
|
||||||
|
it("shows the first preview immediately", () => {
|
||||||
|
const { schedule, emitted } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces a burst into one trailing preview", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
// A pointer sweep across the list, or j held down: every row in between
|
||||||
|
// is walked well inside one gap.
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule("s3");
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1", "s3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the next preview immediately once the gap has passed", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
schedule("s2");
|
||||||
|
expect(emitted).toEqual(["s1", "s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a superseded trailing preview rather than replaying it", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule("s1"); // back where we started inside the same gap
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
// s1 is already on screen, so the trailing edge has nothing to do.
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears through the same gap, so leaving and re-entering does not blink", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule(null); // pointer crosses the gap between two rows
|
||||||
|
schedule("s2");
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1", "s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel drops a pending trailing preview", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule.cancel();
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
|
||||||
|
*
|
||||||
|
* The highlight itself is never scheduled — that is a class on a row and lands
|
||||||
|
* on the same frame as the input. This governs only the pane swap behind it,
|
||||||
|
* which mounts a terminal and, for a session the connection pool isn't already
|
||||||
|
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
|
||||||
|
*
|
||||||
|
* A plain debounce would put that cost in front of every preview, including the
|
||||||
|
* one preview the operator actually meant. Instead the first move shows at once
|
||||||
|
* and the rest of a burst is coalesced: walking a list with `j` held down, or
|
||||||
|
* sweeping the pointer from the top of the sidebar to the bottom, costs one
|
||||||
|
* preview at the start and one at the row you stopped on, not one per row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
|
||||||
|
* couple of terminals rather than one per row; short enough that a deliberate
|
||||||
|
* second move never waits on it. */
|
||||||
|
export const PREVIEW_MIN_GAP_MS = 120;
|
||||||
|
|
||||||
|
export interface PreviewScheduleDeps {
|
||||||
|
now?: () => number;
|
||||||
|
setTimer?: (fn: () => void, ms: number) => number;
|
||||||
|
clearTimer?: (handle: number) => void;
|
||||||
|
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
|
||||||
|
minGapMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewSchedule {
|
||||||
|
(sessionId: string | null): void;
|
||||||
|
/** Drop a pending trailing preview without showing it. */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap `show` so it fires on the leading edge and at most once per gap.
|
||||||
|
*
|
||||||
|
* `show` is called with the session to preview, or `null` to put back whatever
|
||||||
|
* the operator actually opened.
|
||||||
|
*/
|
||||||
|
export function createPreviewSchedule(
|
||||||
|
show: (sessionId: string | null) => void,
|
||||||
|
deps: PreviewScheduleDeps = {},
|
||||||
|
): PreviewSchedule {
|
||||||
|
const now = deps.now ?? (() => Date.now());
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
|
||||||
|
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
|
||||||
|
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
|
||||||
|
|
||||||
|
let lastShownAt = Number.NEGATIVE_INFINITY;
|
||||||
|
let shown: string | null | undefined;
|
||||||
|
let pendingTimer: number | undefined;
|
||||||
|
let pendingId: string | null = null;
|
||||||
|
|
||||||
|
const emit = (id: string | null) => {
|
||||||
|
// Nothing to do when the burst ended on the view already up: re-showing it
|
||||||
|
// would tear down and re-mount the pane for no visible change.
|
||||||
|
if (shown === id) return;
|
||||||
|
shown = id;
|
||||||
|
lastShownAt = now();
|
||||||
|
show(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = () => {
|
||||||
|
if (pendingTimer === undefined) return;
|
||||||
|
clearTimer(pendingTimer);
|
||||||
|
pendingTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = ((sessionId: string | null) => {
|
||||||
|
pendingId = sessionId;
|
||||||
|
const waited = now() - lastShownAt;
|
||||||
|
if (waited >= minGap) {
|
||||||
|
cancelPending();
|
||||||
|
emit(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
|
||||||
|
pendingTimer = setTimer(() => {
|
||||||
|
pendingTimer = undefined;
|
||||||
|
emit(pendingId);
|
||||||
|
}, minGap - waited);
|
||||||
|
}) as PreviewSchedule;
|
||||||
|
|
||||||
|
schedule.cancel = cancelPending;
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
|||||||
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
||||||
|
|
||||||
/** Two sessions in sibling directories: two folder headers, two cards. */
|
/** Two sessions in sibling directories: two folder headers, two cards. */
|
||||||
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
|
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
|
||||||
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
|
el: HTMLElement;
|
||||||
|
handle: ReturnType<typeof renderSidebar>;
|
||||||
|
} {
|
||||||
|
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
|
||||||
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
||||||
document.body.replaceChildren(handle.el);
|
document.body.replaceChildren(handle.el);
|
||||||
return { el: handle.el, handle };
|
return { el: handle.el, handle };
|
||||||
@@ -117,6 +120,45 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(asked).toBe(1);
|
expect(asked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("c collapses and expands the highlighted folder", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
||||||
|
header.click(); // collapse /one, so the walk stops on its header
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
expect(highlighted(el)).toBe("folder:one");
|
||||||
|
press(tree(el), "c");
|
||||||
|
// Expanded: the card it was hiding is back in the walk.
|
||||||
|
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("r renames the highlighted session in place", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "r");
|
||||||
|
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(field).not.toBeNull();
|
||||||
|
// Escape settles the rename, so the module-level "a row is being renamed"
|
||||||
|
// flag doesn't leak into the next test.
|
||||||
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("x kills the highlighted session, once confirmed (#160)", async () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const killed: string[] = [];
|
||||||
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof window.fetch;
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "x");
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the keyboard alone while a row is being renamed", () => {
|
it("leaves the keyboard alone while a row is being renamed", () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
@@ -126,3 +168,40 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(highlighted(el)).toBe(`session:${A}`);
|
expect(highlighted(el)).toBe(`session:${A}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||||
|
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
||||||
|
const seen: (string | null)[] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "j");
|
||||||
|
expect(seen).toEqual([A, B]);
|
||||||
|
|
||||||
|
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||||
|
expect(seen.at(-1)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||||
|
const seen: (string | null)[] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toEqual([A]);
|
||||||
|
// Moving within the same card is not a new preview.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
|
expect(seen.at(-1)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores touch, where a tap already opens the session", () => {
|
||||||
|
const seen: (string | null)[] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
//
|
||||||
|
// The sidebar's search field is the touch entry point to the command bar
|
||||||
|
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
|
||||||
|
// shell to open the bar with an empty query, instead of filtering the tree.
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||||
|
import { sessionBarCandidates } from "./commandBar.js";
|
||||||
|
import { matchAndRank } from "./matcher.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(id: string, cwd: string): Session {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
cwd,
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
} as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
|
||||||
|
const handle = renderSidebar(storeWith(sessions));
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
|
||||||
|
const opens: number[] = [];
|
||||||
|
const listener = () => opens.push(1);
|
||||||
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
search,
|
||||||
|
opens,
|
||||||
|
dispose: () => {
|
||||||
|
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||||
|
handle.dispose();
|
||||||
|
handle.el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar search field opens the command bar (#157)", () => {
|
||||||
|
it("asks the shell to open the bar when the field is focused", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.dispatchEvent(new FocusEvent("focus"));
|
||||||
|
expect(t.opens).toHaveLength(1);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on a tap, where a focus event may never arrive", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(t.opens).toHaveLength(1);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trap the keyboard in the field: it gives focus up on open", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.focus();
|
||||||
|
expect(document.activeElement).not.toBe(t.search);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the bar with an empty query — the field never holds one", () => {
|
||||||
|
const t = mount();
|
||||||
|
t.search.focus();
|
||||||
|
expect(t.search.value).toBe("");
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
|
||||||
|
const t = mount();
|
||||||
|
expect(t.search.readOnly).toBe(true);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer filters the tree in place", () => {
|
||||||
|
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
|
||||||
|
t.search.value = "alpha";
|
||||||
|
t.search.dispatchEvent(new Event("input"));
|
||||||
|
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
|
||||||
|
const t = mount([]);
|
||||||
|
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
|
||||||
|
t.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The inline filter matched a session on its name and its directory, case- and
|
||||||
|
// whitespace-insensitively. That behaviour did not go away with the field — it
|
||||||
|
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
|
||||||
|
describe("what the retired inline filter did, the bar now does", () => {
|
||||||
|
it("matches a session on both its name and its directory", () => {
|
||||||
|
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
|
||||||
|
const candidates = sessionBarCandidates(sessions, "/home/n");
|
||||||
|
|
||||||
|
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
|
||||||
|
expect(byName).toEqual(["alpha"]);
|
||||||
|
|
||||||
|
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
|
||||||
|
expect(byDir).toEqual(["beta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
|
||||||
|
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
|
||||||
|
|
||||||
|
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
|
||||||
|
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+281
-30
@@ -87,6 +87,30 @@
|
|||||||
--accent-tint: oklch(0.92 0.005 256 / 0.1);
|
--accent-tint: oklch(0.92 0.005 256 / 0.1);
|
||||||
--accent-ring: oklch(0.92 0.005 256 / 0.5);
|
--accent-ring: oklch(0.92 0.005 256 / 0.5);
|
||||||
|
|
||||||
|
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
|
||||||
|
the accent, because the accent is what says "this is the one you picked".
|
||||||
|
Defined once, off a single hue variable, so a palette whose accent already
|
||||||
|
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
|
||||||
|
keeps the light pair here instead of in all eighteen theme blocks. */
|
||||||
|
--preview-h: 305;
|
||||||
|
--preview: light-dark(
|
||||||
|
oklch(0.55 0.16 var(--preview-h)),
|
||||||
|
oklch(0.78 0.14 var(--preview-h))
|
||||||
|
);
|
||||||
|
--preview-ink: light-dark(
|
||||||
|
oklch(0.99 0.005 var(--preview-h)),
|
||||||
|
oklch(0.19 0.045 var(--preview-h))
|
||||||
|
);
|
||||||
|
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
|
||||||
|
the same colour whatever tier the row was already painted. `--accent-fill`
|
||||||
|
is the same recipe on the accent, so the two tiers differ by hue alone. */
|
||||||
|
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
|
||||||
|
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
|
||||||
|
--preview-ring: light-dark(
|
||||||
|
oklch(0.55 0.16 var(--preview-h) / 0.5),
|
||||||
|
oklch(0.78 0.14 var(--preview-h) / 0.6)
|
||||||
|
);
|
||||||
|
|
||||||
--live: oklch(0.84 0.17 152);
|
--live: oklch(0.84 0.17 152);
|
||||||
--live-glow: oklch(0.84 0.17 152 / 0.55);
|
--live-glow: oklch(0.84 0.17 152 / 0.55);
|
||||||
|
|
||||||
@@ -256,6 +280,9 @@
|
|||||||
--accent-ink: oklch(0.18 0.04 278);
|
--accent-ink: oklch(0.18 0.04 278);
|
||||||
--accent-tint: oklch(0.75 0.125 275 / 0.14);
|
--accent-tint: oklch(0.75 0.125 275 / 0.14);
|
||||||
--accent-ring: oklch(0.75 0.125 275 / 0.55);
|
--accent-ring: oklch(0.75 0.125 275 / 0.55);
|
||||||
|
/* This accent is already violet, so preview moves to cyan to stay legible
|
||||||
|
as "not the accent". Set once here: the light blocks below don't touch it. */
|
||||||
|
--preview-h: 195;
|
||||||
--term-bg: oklch(0.155 0.018 278);
|
--term-bg: oklch(0.155 0.018 278);
|
||||||
}
|
}
|
||||||
:root[data-palette="periwinkle"][data-theme="light"],
|
:root[data-palette="periwinkle"][data-theme="light"],
|
||||||
@@ -437,6 +464,8 @@
|
|||||||
--accent-ink: oklch(0.22 0.05 310);
|
--accent-ink: oklch(0.22 0.05 310);
|
||||||
--accent-tint: oklch(0.78 0.13 310 / 0.14);
|
--accent-tint: oklch(0.78 0.13 310 / 0.14);
|
||||||
--accent-ring: oklch(0.78 0.13 310 / 0.55);
|
--accent-ring: oklch(0.78 0.13 310 / 0.55);
|
||||||
|
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
|
||||||
|
--preview-h: 195;
|
||||||
--term-bg: oklch(0.155 0.014 305);
|
--term-bg: oklch(0.155 0.014 305);
|
||||||
}
|
}
|
||||||
:root[data-palette="orchid"][data-theme="light"],
|
:root[data-palette="orchid"][data-theme="light"],
|
||||||
@@ -1064,7 +1093,6 @@ textarea {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
font-size: var(--fs-12);
|
||||||
transition: color var(--dur-fast) var(--ease-out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-folder-header:hover {
|
.tree-folder-header:hover {
|
||||||
@@ -1076,9 +1104,9 @@ textarea {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
transition:
|
/* The rotation is the collapse animation and stays; the hover colour is
|
||||||
transform var(--dur) var(--ease-out),
|
immediate, like the row's own hover. */
|
||||||
color var(--dur-fast) var(--ease-out);
|
transition: transform var(--dur) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-folder-header[aria-expanded="false"] .chevron {
|
.tree-folder-header[aria-expanded="false"] .chevron {
|
||||||
@@ -1115,33 +1143,42 @@ textarea {
|
|||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition:
|
/* No transition on `background`: it is what hover, the keyboard cursor and
|
||||||
background var(--dur-fast) var(--ease-out),
|
the preview all paint, and easing it puts a visible delay between the input
|
||||||
box-shadow var(--dur-fast) var(--ease-out);
|
landing on a row and the row admitting it. `.is-active`'s ring still eases
|
||||||
|
— being open in a pane is a state, not a response to the pointer. */
|
||||||
|
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
|
||||||
.tree-leaf[data-status="running"] {
|
once, so every qualifier below sits in `:where()`: each rule is worth the
|
||||||
|
same specificity and the ladder is decided purely by the order they appear
|
||||||
|
in, latest wins. Add a tier where it belongs in that order, not by piling on
|
||||||
|
another class to out-weigh the rule above it. Each tier paints a background
|
||||||
|
and nothing else — the list says all of this with fill, never an edge. */
|
||||||
|
|
||||||
|
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
|
||||||
|
.tree-leaf:where([data-status="running"]) {
|
||||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf:hover {
|
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
|
||||||
background: var(--surface-2);
|
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
|
||||||
|
which belongs to the row actually open. */
|
||||||
|
.tree-leaf:where(:hover, .is-highlighted) {
|
||||||
|
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf[data-status="running"]:hover {
|
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
|
||||||
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
|
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf:focus-visible {
|
.tree-leaf:where(:focus-visible) {
|
||||||
outline: none;
|
outline: none;
|
||||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
/* A folder row previews nothing, so its cursor stays a ring. */
|
||||||
is a cursor, not a statement about what is mounted, so it reads as a ring
|
|
||||||
rather than a fill and the two can sit on the same row. */
|
|
||||||
.tree-leaf.is-highlighted,
|
|
||||||
.tree-folder-header.is-highlighted {
|
.tree-folder-header.is-highlighted {
|
||||||
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
||||||
}
|
}
|
||||||
@@ -1150,17 +1187,28 @@ textarea {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The session currently mounted in the content pane. */
|
/* 3. Open in some pane, but not the focused one (issue #33). */
|
||||||
.tree-leaf.is-active,
|
.tree-leaf:where(.is-open) {
|
||||||
.tree-leaf.is-active[data-status="running"] {
|
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||||
background: var(--accent-tint);
|
}
|
||||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
|
||||||
|
/* 4. The session currently mounted in the content pane. */
|
||||||
|
.tree-leaf:where(.is-active) {
|
||||||
|
background: var(--accent-fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf.is-active .leaf-name {
|
.tree-leaf.is-active .leaf-name {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||||
|
session that is already open still reads as provisional — except the session
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||||
never the running tint's absence alone doing the work. */
|
never the running tint's absence alone doing the work. */
|
||||||
.tree-leaf[data-status="dead"] {
|
.tree-leaf[data-status="dead"] {
|
||||||
@@ -1377,6 +1425,171 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The action page's header (#151): names the session being acted on, so the
|
||||||
|
target of kill/rename/spawn-similar is never ambiguous. */
|
||||||
|
.cmdbar-page-header {
|
||||||
|
padding: var(--sp-2) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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. */
|
||||||
|
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
|
||||||
|
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
|
||||||
|
search field (#157), so it rides the bottom of the screen. */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.cmdbar-overlay {
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdbar-panel {
|
||||||
|
grid-row: 2;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70dvh;
|
||||||
|
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb-sized rows and a field that clears the on-screen keyboard's
|
||||||
|
zoom-on-focus threshold (16px). */
|
||||||
|
.cmdbar-input {
|
||||||
|
font-size: var(--fs-17);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdbar-row {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 {
|
||||||
@@ -2883,6 +3096,42 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
|
||||||
|
operator only highlighted, so it reads as provisional — the preview hue on
|
||||||
|
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
|
||||||
|
reverts when the highlight moves. The hue is deliberately not the accent:
|
||||||
|
the accent means "this is what you picked", which a preview is not. */
|
||||||
|
.mv-tab.is-preview {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--preview-ring);
|
||||||
|
color: var(--preview);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.mv.mv-previewing .mv-canvas::before {
|
||||||
|
content: "preview";
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-2);
|
||||||
|
right: var(--sp-3);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
padding: 1px var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
font: 500 var(--fs-11) / 1.5 var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--preview-ink);
|
||||||
|
background: var(--preview);
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.mv.mv-previewing .mv-canvas::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px dashed var(--preview-ring);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
/* canvas + welcome */
|
/* canvas + welcome */
|
||||||
.mv-canvas {
|
.mv-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2996,6 +3245,15 @@ input.leaf-rename:focus {
|
|||||||
.mv-pane:not(.is-focused) .attach-header {
|
.mv-pane:not(.is-focused) .attach-header {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
|
||||||
|
only thing saying the pane is provisional — and so it is never mistaken for
|
||||||
|
the accent tint of the pane the operator actually focused. It keeps full
|
||||||
|
strength even though a preview never takes focus. */
|
||||||
|
.mv-pane.is-preview .attach-header,
|
||||||
|
.mv-pane.is-preview.is-focused .attach-header {
|
||||||
|
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
|
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
|
||||||
.mv-pane-empty {
|
.mv-pane-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -3085,13 +3343,6 @@ input.leaf-rename:focus {
|
|||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sidebar two-tier highlight (issue #33).
|
|
||||||
.is-active = focused pane (loud, from existing rule); .is-open = open
|
|
||||||
elsewhere (quiet). */
|
|
||||||
.tree-leaf.is-open {
|
|
||||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||||
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||||
|
|||||||
+2
-26
@@ -26,7 +26,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root)).toEqual(["~/work"]);
|
expect(names(root)).toEqual(["~/work"]);
|
||||||
@@ -35,7 +34,7 @@ describe("buildTree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("collapses a linear chain into one folder", () => {
|
it("collapses a linear chain into one folder", () => {
|
||||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
|
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
|
||||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||||
@@ -53,7 +51,6 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(names(root)).toEqual(["~/git"]);
|
expect(names(root)).toEqual(["~/git"]);
|
||||||
@@ -65,30 +62,11 @@ describe("buildTree", () => {
|
|||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("loose", ""), session("nested", "/home/n/work")],
|
[session("loose", ""), session("nested", "/home/n/work")],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(root.children[0].kind).toBe("folder");
|
expect(root.children[0].kind).toBe("folder");
|
||||||
expect(root.children[1].kind).toBe("leaf");
|
expect(root.children[1].kind).toBe("leaf");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters on both session name and directory", () => {
|
|
||||||
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
|
||||||
|
|
||||||
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
|
|
||||||
expect(names(byDir)).toEqual(["~/alpha"]);
|
|
||||||
|
|
||||||
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
|
|
||||||
expect(names(byCommand)).toEqual(["~/alpha"]);
|
|
||||||
|
|
||||||
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
|
|
||||||
expect(noMatch.children).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores case and surrounding whitespace in the filter", () => {
|
|
||||||
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
|
|
||||||
expect(root.children).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mergeLinear", () => {
|
describe("mergeLinear", () => {
|
||||||
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
"/home/n",
|
"/home/n",
|
||||||
"",
|
|
||||||
) as TreeFolder;
|
) as TreeFolder;
|
||||||
|
|
||||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
@@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a session without a worktree where it is", () => {
|
it("leaves a session without a worktree where it is", () => {
|
||||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
|
||||||
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-6
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
|
|||||||
return an.localeCompare(bn);
|
return an.localeCompare(bn);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
|
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
|
||||||
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
||||||
const lc = filter.trim().toLowerCase();
|
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
// Grouped by repository, so a worktree session lands under its repo
|
// Grouped by repository, so a worktree session lands under its repo
|
||||||
// rather than in a folder of its own (#99).
|
// rather than in a folder of its own (#99).
|
||||||
const cwd = sessionTreePath(s);
|
const cwd = sessionTreePath(s);
|
||||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||||
if (lc) {
|
|
||||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
|
||||||
if (!haystack.includes(lc)) continue;
|
|
||||||
}
|
|
||||||
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
||||||
let cursor = root;
|
let cursor = root;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
|
|||||||
+14
-1
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
|
|||||||
export type Uuid = string;
|
export type Uuid = string;
|
||||||
export type IsoTimestamp = string;
|
export type IsoTimestamp = string;
|
||||||
|
|
||||||
export type SessionStatus = "Running" | "Dead";
|
/** `Dormant` (#162) is a session the daemon knows only from its sidecar —
|
||||||
|
* recovered after the tmux server died under it, so there is no pane behind it
|
||||||
|
* and nothing to attach to. Listed, never live. */
|
||||||
|
export type SessionStatus = "Running" | "Dead" | "Dormant";
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
@@ -50,6 +53,12 @@ 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). */
|
||||||
@@ -92,6 +101,10 @@ export interface DaemonInfo {
|
|||||||
* value across a reconnect means the daemon restarted. Absent on older
|
* value across a reconnect means the daemon restarted. Absent on older
|
||||||
* daemons, which the restart flow treats as "cannot tell". */
|
* daemons, which the restart flow treats as "cannot tell". */
|
||||||
instance?: string;
|
instance?: string;
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
|
||||||
|
* empty on an older daemon; the client then keeps its own default. */
|
||||||
|
leader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build provenance of the running daemon: what the status indicator names in
|
/** Build provenance of the running daemon: what the status indicator names in
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MAX_CONTEXT_LOSSES, WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
|
describe("WebglPolicy", () => {
|
||||||
|
it("is enabled when the browser supports WebGL2", () => {
|
||||||
|
expect(new WebglPolicy(() => true).enabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when the browser has no WebGL2", () => {
|
||||||
|
expect(new WebglPolicy(() => false).enabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes only once, however often it is asked", () => {
|
||||||
|
const probe = vi.fn(() => true);
|
||||||
|
const policy = new WebglPolicy(probe);
|
||||||
|
policy.enabled();
|
||||||
|
policy.enabled();
|
||||||
|
policy.enabled();
|
||||||
|
expect(probe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a loss below the budget — a backgrounded tab is ordinary", () => {
|
||||||
|
const policy = new WebglPolicy(() => true);
|
||||||
|
policy.noteContextLoss();
|
||||||
|
expect(policy.enabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up for good once losses reach the budget", () => {
|
||||||
|
const policy = new WebglPolicy(() => true);
|
||||||
|
for (let i = 0; i < MAX_CONTEXT_LOSSES; i++) policy.noteContextLoss();
|
||||||
|
expect(policy.enabled()).toBe(false);
|
||||||
|
expect(policy.contextLosses()).toBe(MAX_CONTEXT_LOSSES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays disabled past the budget, and never bothers probing", () => {
|
||||||
|
const probe = vi.fn(() => true);
|
||||||
|
const policy = new WebglPolicy(probe);
|
||||||
|
for (let i = 0; i < MAX_CONTEXT_LOSSES + 3; i++) policy.noteContextLoss();
|
||||||
|
expect(policy.enabled()).toBe(false);
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a throwing probe as unsupported", () => {
|
||||||
|
const policy = new WebglPolicy(() => {
|
||||||
|
throw new Error("no gl for you");
|
||||||
|
});
|
||||||
|
expect(() => policy.enabled()).not.toThrow();
|
||||||
|
expect(policy.enabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no WebGL2 under the test DOM, which has no GPU", () => {
|
||||||
|
expect(new WebglPolicy().enabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// WebGL renderer policy for attached terminals. Split out of attach.ts so it
|
||||||
|
// can be tested without a DOM or an xterm instance (same reasoning as
|
||||||
|
// reconnect.ts).
|
||||||
|
//
|
||||||
|
// xterm's default renderer builds DOM nodes per cell, which is CPU-bound —
|
||||||
|
// exactly the wrong bottleneck on a phone, where a full-screen repaint from an
|
||||||
|
// agent dumping output visibly stutters. The WebGL addon moves glyph drawing to
|
||||||
|
// a GPU texture atlas instead.
|
||||||
|
//
|
||||||
|
// The cost is that a WebGL context is a scarce, revocable resource:
|
||||||
|
//
|
||||||
|
// * Browsers cap live contexts (~16 on desktop Chrome, fewer on Android) and
|
||||||
|
// silently kill the oldest past the cap. `ConnectionPool` keeps MAX_PARKED
|
||||||
|
// parked connections alive on top of the mounted ones, so handing every
|
||||||
|
// connection its own context would blow that budget on its own and bring a
|
||||||
|
// parked terminal back blank.
|
||||||
|
// * Android drops contexts as routine housekeeping — backgrounding the tab,
|
||||||
|
// locking the screen, memory pressure.
|
||||||
|
//
|
||||||
|
// So the context follows the *view*, not the connection: acquired on mount,
|
||||||
|
// released on park. Losses count against a shared budget, because a device that
|
||||||
|
// keeps dropping contexts is telling us it cannot sustain one.
|
||||||
|
|
||||||
|
/** Context losses tolerated before the page gives up and stays on the DOM
|
||||||
|
* renderer. One loss is ordinary (a tab switch); repeated losses mean the
|
||||||
|
* device is thrashing, and re-acquiring only makes it worse. */
|
||||||
|
export const MAX_CONTEXT_LOSSES = 2;
|
||||||
|
|
||||||
|
/** Can this browser hand out a WebGL2 context at all? */
|
||||||
|
export function probeWebgl2(): boolean {
|
||||||
|
return document.createElement("canvas").getContext("webgl2") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an attached terminal should try the WebGL renderer right now.
|
||||||
|
*
|
||||||
|
* Shared by every connection on the page: the context budget and the "this
|
||||||
|
* device cannot cope" signal are both global, not per-terminal.
|
||||||
|
*/
|
||||||
|
export class WebglPolicy {
|
||||||
|
private supported: boolean | null = null;
|
||||||
|
private losses = 0;
|
||||||
|
|
||||||
|
constructor(private readonly probe: () => boolean = probeWebgl2) {}
|
||||||
|
|
||||||
|
/** True while WebGL is both available and still worth attempting. The probe
|
||||||
|
* runs at most once — support does not change under us, and creating
|
||||||
|
* throwaway contexts to re-ask would itself consume the budget. */
|
||||||
|
enabled(): boolean {
|
||||||
|
if (this.losses >= MAX_CONTEXT_LOSSES) return false;
|
||||||
|
if (this.supported === null) {
|
||||||
|
try {
|
||||||
|
this.supported = this.probe();
|
||||||
|
} catch {
|
||||||
|
// A browser that throws rather than returning null has no usable
|
||||||
|
// context either way.
|
||||||
|
this.supported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a lost context. Past the budget, `enabled()` stays false for the
|
||||||
|
* life of the page. */
|
||||||
|
noteContextLoss(): void {
|
||||||
|
this.losses += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Losses recorded so far — for the debug HUD and tests. */
|
||||||
|
contextLosses(): number {
|
||||||
|
return this.losses;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user