Compare commits

..
Author SHA1 Message Date
Miguel Palhas 9728b10ffd feat(maestro): add preset resume commands 2026-09-04 08:33:08 +01:00
Miguel Palhas 1b2b392710 merge: dormant session recovery (#162)
Closes #162
2026-09-04 08:25:44 +01:00
Miguel PalhasandClaude Opus 5 72644dd564 feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.

`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:22:52 +01:00
Miguel Palhas 8325bc6d9a Merge remote-tracking branch 'origin/main' into blitz/command-bar
ci / web (push) Successful in 37s
ci / flake (push) Successful in 2m52s
ci / rust (push) Successful in 5m44s
2026-09-03 22:58:41 +01:00
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas 511fed408c feat(maestro): confirm every kill in the web client
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).

The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
2026-09-03 22:08:06 +01:00
Miguel Palhas ba02b11f13 feat(maestro): serve the configured leader to the web client
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.

`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.

Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
2026-09-03 18:33:01 +01:00
Miguel PalhasandClaude Opus 5 59ede15d2b feat(maestro): open the bar from the sidebar search field
There is no ctrl-k on a phone and the web UI is mobile-first, so the field
the operator already reaches for becomes the touch entry point: focusing or
tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with
an empty query. The field is readonly and blurs itself first, so no keyboard
rises over it and the bar can be closed without reopening it.

This replaces the inline substring filter — one search affordance in the
header, not two. buildTree loses its filter argument, and the tree's "No
matches" empty state goes with it; the bar's own no-results state took over.
The filter's name-and-directory matching is now covered against the bar.

The bar's phone-width sheet moves to the spawn modal's 599px break, with
thumb-sized rows and a field that clears the zoom-on-focus threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:21:31 +01:00
Miguel PalhasandClaude Sonnet 5 331039df11 fix(maestro): leader twice sends one literal leader byte
ctrl-a is consumed as the leader on every press, so it never reaches the
shell and readline's beginning-of-line was gone with no way back — unlike
Ctrl-K, which already has an escape hatch. Fix it the way tmux does:
pressing the leader twice sends the literal leader byte to the focused
pane and clears the pending chord, on both web and TUI, built from the
configured leader so a rebound leader carries it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:14:11 +01:00
Miguel Palhas 151438094e Merge remote-tracking branch 'origin/blitz/command-bar' into command-bar/158-literal-leader 2026-09-03 17:59:51 +01:00
Miguel Palhas c1c283c0f8 feat(maestro): give readline's kill-to-end-of-line back via the leader
ctrl-k now opens the command bar in both modes (#153), taking readline's
kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a
ctrl-k by default — sends the literal 0x0B byte to the focused pane
instead, built from the configured leader so rebinding it rebinds the
chord too.

Chording a second binding off the leader exposed a gap in ChordState:
a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a
ctrl-k) would get stuck waiting on the next keystroke, discarding its
own meaning the moment that keystroke didn't continue the chord. Feed
now resolves the held prefix to its own binding when that happens and
hands the breaking key back via take_replay for the caller to process
fresh — otherwise, pressing leader then any other key would never
leave Insert mode again.
2026-09-03 17:55:50 +01:00
Miguel PalhasandClaude Opus 5 1b260b5c20 feat(maestro): drill into a session's actions with tab
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.

Each action raises the flow the TUI already has — `KillConfirm`, the
rename prompt pre-filled, and the spawn form, now seeded from that
session's directory and command rather than the focused one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:35:24 +01:00
Miguel Palhas f8b434dd1d fix(maestro): keep accent fill when previewing active row
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m34s
ci / rust (push) Successful in 6m44s
Hovering the session already in the pane put the preview tint over its accent fill, so the row that is on screen looked unpicked. Tier 5 now skips `.is-active`.
2026-09-03 16:34:58 +01:00
47 changed files with 2451 additions and 259 deletions
+1
View File
@@ -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",
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: None,
}], }],
..Default::default() ..Default::default()
}); });
+57
View File
@@ -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 {
@@ -299,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(), id: "claude".into(),
label: "Claude".into(), label: "Claude".into(),
command: "claude".into(), command: "claude".into(),
resume_command: Some("claude --resume {agent_session}".into()),
}, },
SpawnPreset { SpawnPreset {
id: "opencode".into(), id: "opencode".into(),
label: "opencode".into(), label: "opencode".into(),
command: "opencode".into(), command: "opencode".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "zsh".into(), id: "zsh".into(),
label: "Shell".into(), label: "Shell".into(),
command: "zsh".into(), command: "zsh".into(),
resume_command: None,
}, },
] ]
} }
@@ -320,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(), id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(), label: "Custom…".into(),
command: String::new(), command: String::new(),
resume_command: None,
} }
} }
@@ -493,6 +511,18 @@ auto_install = false
assert!(custom.command.is_empty()); assert!(custom.command.is_empty());
} }
#[test]
fn builtin_claude_preset_resumes_by_agent_session() {
let claude = builtin_presets()
.into_iter()
.find(|preset| preset.id == "claude")
.expect("Claude preset");
assert_eq!(
claude.resume_command.as_deref(),
Some("claude --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_presets_override_replaces_builtins() { fn spawn_presets_override_replaces_builtins() {
// Present list replaces the built-ins entirely; custom still appended. // Present list replaces the built-ins entirely; custom still appended.
@@ -501,6 +531,7 @@ auto_install = false
id: "fish".into(), id: "fish".into(),
label: "fish".into(), label: "fish".into(),
command: "fish".into(), command: "fish".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -537,6 +568,7 @@ auto_install = false
id: " ".into(), id: " ".into(),
label: "x".into(), label: "x".into(),
command: "x".into(), command: "x".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -547,6 +579,7 @@ auto_install = false
id: "x".into(), id: "x".into(),
label: "x".into(), label: "x".into(),
command: "".into(), command: "".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -564,11 +597,13 @@ auto_install = false
id: "a".into(), id: "a".into(),
label: "a".into(), label: "a".into(),
command: "a".into(), command: "a".into(),
resume_command: None,
}, },
SpawnPreset { SpawnPreset {
id: "a".into(), id: "a".into(),
label: "a2".into(), label: "a2".into(),
command: "a2".into(), command: "a2".into(),
resume_command: None,
}, },
]), ]),
..Default::default() ..Default::default()
@@ -583,6 +618,7 @@ auto_install = false
id: "custom".into(), id: "custom".into(),
label: "Custom".into(), label: "Custom".into(),
command: "whatever".into(), command: "whatever".into(),
resume_command: None,
}]), }]),
..Default::default() ..Default::default()
}; };
@@ -618,6 +654,27 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]); assert_eq!(ids, ["claude", "zsh", "custom"]);
} }
#[test]
fn spawn_preset_parses_resume_command_from_issue_example() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
presets = [
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
]
"#,
)
.expect("issue #165 example parses");
let preset = &cfg.spawn.presets.expect("configured presets")[0];
assert_eq!(preset.id, "claude");
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
assert_eq!(
preset.resume_command.as_deref(),
Some("claude --dangerously-skip-permissions --resume {agent_session}")
);
}
#[test] #[test]
fn spawn_project_scan_fields_default_to_none() { fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults. // Absent [spawn].project_* → None; the daemon applies its own defaults.
+9 -1
View File
@@ -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;
} }
+1
View File
@@ -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?;
+29
View File
@@ -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();
+1
View File
@@ -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;
+7
View File
@@ -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(),
}) })
} }
@@ -374,6 +379,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 +387,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()),
} }
} }
+176 -22
View File
@@ -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,7 +294,10 @@ 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
@@ -298,19 +316,26 @@ impl SessionManager {
let (result, updated) = { let (result, updated) = {
let mut sessions = self.inner.sessions.lock().await; let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id)?; let rec = sessions.get_mut(&id)?;
rec.session.last_focused_at = Some(Utc::now()); let SessionRecord {
let updated = rec.session.clone(); session, bridge, ..
} = &mut *rec;
// #162: a dormant session has no bridge and no pane — nothing was
// ever piped for it, so there is no stream to hand back and the
// attach reads as unknown. Checked before anything is stamped.
let bridge = bridge.as_ref()?;
session.last_focused_at = Some(Utc::now());
let updated = session.clone();
// For a live session, tmux's own screen beats the byte tail: the // 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) // tail starts mid-stream, so a full-screen program (an agent TUI)
// replayed from it renders as garbage until something forces a // replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it // repaint. A dead session has no pane left to capture, so it
// keeps the byte tail. // keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running; let alive = session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, // The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip. // which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in // That only happens on attach, and the alternative is a seam in
// the stream. // the stream.
let result = rec.bridge.subscribe_with_snapshot(|| { let result = bridge.subscribe_with_snapshot(|| {
if !alive { if !alive {
return None; return None;
} }
@@ -487,7 +512,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")]
@@ -695,7 +720,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")]
@@ -710,16 +735,89 @@ 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.
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 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;
}
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"
);
}
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();
@@ -732,13 +830,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();
@@ -756,12 +856,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();
@@ -770,6 +870,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.
/// ///
@@ -1116,7 +1262,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.
@@ -1234,8 +1384,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;
@@ -1244,7 +1396,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
+186
View File
@@ -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);
}
}
+1
View File
@@ -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
+1
View File
@@ -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
+41
View File
@@ -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
@@ -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,140 @@ 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);
}
/// #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
+1
View File
@@ -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
+65 -1
View File
@@ -123,10 +123,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
@@ -489,8 +517,11 @@ impl ApiError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset { pub struct SpawnPreset {
pub id: String, pub id: String,
#[serde(default)]
pub label: String, pub label: String,
pub command: String, pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
} }
/// Build provenance of the running daemon: the commit it was compiled /// Build provenance of the running daemon: the commit it was compiled
@@ -554,6 +585,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
@@ -1053,6 +1090,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]
+27
View File
@@ -56,6 +56,14 @@ pub enum Action {
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line /// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
/// over the sessions, ranked by the shared matcher. /// over the sessions, ranked by the shared matcher.
OpenCommandBar, 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,
@@ -98,6 +106,8 @@ impl Action {
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::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(),
@@ -125,6 +135,8 @@ impl Action {
"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), "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),
@@ -256,11 +268,26 @@ mod tests {
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); 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::OpenCommandBar,
Action::SendLiteralCtrlK,
Action::RenameHighlighted, Action::RenameHighlighted,
Action::Quit, Action::Quit,
Action::OpenSpawnModal, Action::OpenSpawnModal,
+347 -42
View File
@@ -23,7 +23,7 @@ use uuid::Uuid;
use crate::action::Action; use crate::action::Action;
use crate::catalog::CatalogStore; use crate::catalog::CatalogStore;
use crate::command_bar::{BarOutcome, BarTarget, CommandBar}; use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
use crate::config::TuiConfig; use crate::config::TuiConfig;
use crate::daemon_link::DaemonLink; use crate::daemon_link::DaemonLink;
use crate::draft::{DraftInputResult, DraftPane}; use crate::draft::{DraftInputResult, DraftPane};
@@ -306,6 +306,7 @@ impl App {
return Ok(true); return Ok(true);
} }
} }
self.chord.take_replay();
return Ok(false); return Ok(false);
} }
@@ -325,32 +326,46 @@ impl App {
return Ok(false); return Ok(false);
} }
let known = self.cfg.keymap.known_bindings(self.state.mode); // A key bumped out of an abandoned chord (#156) is replayed as its
match self.chord.feed(&key, &known) { // own keystroke, against the mode dispatch may have just changed.
ChordResult::Pending => Ok(false), let mut current_key = key;
ChordResult::Resolved(canon) => { let mut result = self.chord.feed(
let act = self.cfg.keymap.resolve(self.state.mode, &canon); &current_key,
if matches!(act, Some(Action::Quit)) { &self.cfg.keymap.known_bindings(self.state.mode),
return Ok(true); );
loop {
let canon = match result {
ChordResult::Pending => return Ok(false),
ChordResult::Resolved(canon) => canon,
};
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&current_key).into_iter().collect();
self.perform(effects).await;
} }
let pty_owns_keys = Some(act) => {
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane); let effects = self.dispatch(act);
match act { self.perform(effects).await;
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
None => {}
} }
Ok(false) None => {}
}
match self.chord.take_replay() {
Some(replay_key) => {
let known = self.cfg.keymap.known_bindings(self.state.mode);
result = self.chord.feed(&replay_key, &known);
current_key = replay_key;
}
None => return Ok(false),
} }
} }
} }
@@ -479,6 +494,25 @@ impl App {
self.close_command_bar(); self.close_command_bar();
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)]) (OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
} }
// The action page raises the flow this client already has
// — for kill that is `KillConfirm`, which the TUI puts in
// front of every kill (#160). The bar names the target;
// it never reimplements the flow.
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
self.close_command_bar();
let effects = match action {
SessionActionId::Kill => {
self.confirm_kill(session);
Vec::new()
}
SessionActionId::Rename => {
self.prompt_rename(session);
Vec::new()
}
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
};
(OverlayDecision::Consumed, effects)
}
} }
} }
Some(OverlayKind::Help) => { Some(OverlayKind::Help) => {
@@ -527,6 +561,16 @@ impl App {
self.state.show_overlay(OverlayKind::Help); self.state.show_overlay(OverlayKind::Help);
} }
Action::OpenCommandBar => effects.extend(self.open_command_bar()), Action::OpenCommandBar => effects.extend(self.open_command_bar()),
// Escape hatch (#156): the literal byte, not the bar.
Action::SendLiteralCtrlK => {
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
}
// Escape hatch (#158): the leader's own byte, built from the
// configured leader rather than a hardcoded key.
Action::SendLiteralLeader => {
let leader = self.cfg.leader.clone();
effects.extend(self.forward_to_session(&leader));
}
Action::OpenSpawnModal => { Action::OpenSpawnModal => {
self.open_spawn_modal(); self.open_spawn_modal();
effects.push(Effect::LoadDraft); effects.push(Effect::LoadDraft);
@@ -555,24 +599,12 @@ impl App {
} }
Action::KillHighlighted => { Action::KillHighlighted => {
if let Some(sid) = self.pick_sidebar_target() { if let Some(sid) = self.pick_sidebar_target() {
self.state.show_overlay(OverlayKind::KillConfirm(sid)); self.confirm_kill(sid);
} }
} }
Action::RenameHighlighted => { Action::RenameHighlighted => {
if let Some(sid) = self.pick_sidebar_target() { if let Some(sid) = self.pick_sidebar_target() {
// Pre-fill with the custom name only: a session that has self.prompt_rename(sid);
// none starts empty rather than with its command already
// typed in, which Enter would then pin as the name.
self.rename_input = TextInput::with_value(
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.and_then(crate::sidebar::custom_name_for)
.unwrap_or_default(),
);
self.state.show_overlay(OverlayKind::Rename(sid));
} }
} }
} }
@@ -858,6 +890,30 @@ impl App {
Some(None) Some(None)
} }
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
/// Shared by the keymap's own kill and the command bar's action page
/// (#155), which picks the action and hands off rather than reimplementing
/// it; the blockers the dialog names are read at render time.
fn confirm_kill(&mut self, sid: Uuid) {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
/// Raise the rename prompt for one session, pre-filled with its custom
/// name only: a session that has none starts empty rather than with its
/// command already typed in, which Enter would then pin as the name.
fn prompt_rename(&mut self, sid: Uuid) {
self.rename_input = TextInput::with_value(
self.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.and_then(crate::sidebar::custom_name_for)
.unwrap_or_default(),
);
self.state.show_overlay(OverlayKind::Rename(sid));
}
/// Put the spawn form up with a fresh draft behind it, preselecting the /// Put the spawn form up with a fresh draft behind it, preselecting the
/// project of the session the operator was last on — so `n` then Enter /// project of the session the operator was last on — so `n` then Enter
/// spawns another session there, the way "spawn similar" would. A /// spawns another session there, the way "spawn similar" would. A
@@ -871,6 +927,30 @@ impl App {
self.state.open_spawn_modal(); self.state.open_spawn_modal();
} }
/// "Spawn similar" (#155): the spawn form seeded from one session's
/// directory and command, rather than from whatever the operator was last
/// looking at. Same base-repository rule as the plain seed, and the name
/// is left out on purpose — a clone is a new session and takes its own
/// generated handle, like `web/src/spawnSimilar.ts`.
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
let session = self
.catalog
.snapshot()
.sessions
.iter()
.find(|s| s.id == sid)
.cloned();
self.open_spawn_modal();
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
draft.seed_cwd = Some(match &session.worktree {
Some(wt) => wt.base_repo.clone(),
None => session.cwd.clone(),
});
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
}
vec![Effect::LoadDraft]
}
/// The project of the session the operator was last on — its base /// The project of the session the operator was last on — its base
/// repository for a worktree session, its cwd otherwise. Shared by the /// repository for a worktree session, its cwd otherwise. Shared by the
/// plain spawn modal seed and fast-forward's project pick (#67 review): /// plain spawn modal seed and fast-forward's project pick (#67 review):
@@ -2857,6 +2937,55 @@ mod tests {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods)) CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
} }
fn ctrl_key_event(c: char) -> CtEvent {
key_event(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::CONTROL,
)
}
/// Drive a sequence of key events through an app in Insert mode with a
/// session attached, and report the app afterwards plus every chunk of
/// bytes that reached that session, in order.
async fn drive_insert_sequence(
section: &TuiSection,
events: &[CtEvent],
) -> (App, Vec<Vec<u8>>) {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(41);
let mut app = App::new(
TuiConfig::load(section).unwrap(),
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
DaemonLink::default(),
);
let (tx, mut rx) = mpsc::channel(8);
app.emulators.insert(
sid,
SessionHandle {
tx,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
},
);
app.state.attach_to_focused(sid);
app.state.mode = Mode::Insert;
app.state.focus = Focus::Pane;
for ev in events {
app.handle_event(ev.clone()).await.unwrap();
}
let mut sent = Vec::new();
while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() {
sent.push(bytes);
}
(app, sent)
}
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read /// Escape belongs to the session: full-screen apps (vim, Claude Code) read
/// it constantly, so no Insert-mode default may swallow it. The leader is /// it constantly, so no Insert-mode default may swallow it. The leader is
/// the way back to the sidebar. /// the way back to the sidebar.
@@ -2875,19 +3004,120 @@ mod tests {
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is /// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty. /// the session's — so binding a key to `Noop` hands it to the pty.
///
/// Uses `ctrl-x`, not the leader: the leader is also a chord prefix
/// (#156), so a single fed keystroke on it resolves as `Pending`, not
/// `Resolved` — a different mechanism, covered separately below.
#[tokio::test] #[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() { async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default(); let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into()); section.keymap_insert.insert("ctrl-x".into(), "Noop".into());
let sent = bytes_sent_for( let sent = bytes_sent_for(
&section, &section,
key_event( key_event(
crossterm::event::KeyCode::Char('a'), crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyModifiers::CONTROL,
), ),
) )
.await; .await;
assert_eq!(sent, Some(vec![0x01])); assert_eq!(sent, Some(vec![0x18]));
}
/// Leader then `ctrl-k` sends the literal byte and never opens the bar
/// (#156) — the default leader, `ctrl-a`.
#[tokio::test]
async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[ctrl_key_event('a'), ctrl_key_event('k')],
)
.await;
assert_eq!(sent, vec![vec![0x0b]]);
assert_eq!(app.state.overlay(), None);
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
}
/// The escape hatch is built from the configured leader, not a hardcoded
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156).
#[tokio::test]
async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() {
let section = TuiSection {
leader: Some("ctrl-b".into()),
..Default::default()
};
let (app, sent) =
drive_insert_sequence(&section, &[ctrl_key_event('b'), ctrl_key_event('k')]).await;
assert_eq!(sent, vec![vec![0x0b]]);
assert_eq!(app.state.overlay(), None);
}
/// Leader alone, followed by an ordinary key, still leaves Insert mode —
/// the escape hatch chord doesn't cost the leader its existing meaning
/// (#156).
#[tokio::test]
async fn leader_alone_still_leaves_insert_when_followed_by_another_key() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[
ctrl_key_event('a'),
key_event(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
),
],
)
.await;
assert!(
sent.is_empty(),
"no bytes for the leader chord itself: {sent:?}"
);
assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert");
}
/// Leader pressed twice sends one literal leader byte, tmux-style, and
/// clears the pending chord rather than leaving Insert (#158) — the
/// default leader, `ctrl-a`.
#[tokio::test]
async fn leader_twice_sends_the_literal_leader_byte_and_stays_in_insert() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[ctrl_key_event('a'), ctrl_key_event('a')],
)
.await;
assert_eq!(sent, vec![vec![0x01]]);
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
}
/// The escape hatch is built from the configured leader, not a hardcoded
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#158).
#[tokio::test]
async fn a_rebound_leader_still_reaches_the_literal_leader_escape() {
let section = TuiSection {
leader: Some("ctrl-b".into()),
..Default::default()
};
let (app, sent) =
drive_insert_sequence(&section, &[ctrl_key_event('b'), ctrl_key_event('b')]).await;
assert_eq!(sent, vec![vec![0x02]]);
assert_eq!(app.state.mode, Mode::Insert);
}
/// After the literal-leader escape fires, the chord is gone — a further
/// leader press starts a fresh chord rather than resolving as a
/// three-deep sequence (#158).
#[tokio::test]
async fn leader_twice_then_ctrl_k_is_a_fresh_chord() {
let (_app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[
ctrl_key_event('a'),
ctrl_key_event('a'),
ctrl_key_event('a'),
ctrl_key_event('k'),
],
)
.await;
assert_eq!(sent, vec![vec![0x01], vec![0x0b]]);
} }
#[test] #[test]
@@ -3379,4 +3609,79 @@ mod tests {
}] }]
); );
} }
// ---- the session action page (#155) ----
/// Drill into the second row's session, then pick an action by name.
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
app.dispatch(Action::OpenCommandBar);
app.handle_overlay_input(&InputKey::Down);
app.handle_overlay_input(&InputKey::Tab);
type_into_bar(app, action);
app.handle_overlay_input(&InputKey::Enter)
}
/// The bar picks the action and hands off: kill goes through the confirm
/// this client puts in front of every kill, not straight to the daemon.
#[test]
fn the_action_page_raises_the_existing_kill_confirm() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "kill");
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty(), "the confirm is the whole effect");
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
assert!(app.command_bar.is_none());
}
#[test]
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
let mut app = make_app();
let (alpha, _bravo) = seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "rename");
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
assert_eq!(app.rename_input.value(), "alpha");
assert!(app.command_bar.is_none());
}
/// Spawn similar opens the spawn form seeded from that session's directory
/// and command — not from whatever the operator was last looking at.
#[test]
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
let mut app = make_app();
seed_two_sessions(&mut app);
let (decision, effects) = drill_and_pick(&mut app, "spawn");
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::LoadDraft]);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
let draft = app.draft.as_ref().unwrap();
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
assert!(app.command_bar.is_none());
}
/// Backing out is free: the result-list query survives the round trip.
#[test]
fn escape_on_the_action_page_returns_to_the_result_list() {
let mut app = make_app();
seed_two_sessions(&mut app);
app.dispatch(Action::OpenCommandBar);
type_into_bar(&mut app, "alp");
app.handle_overlay_input(&InputKey::Tab);
assert!(app.command_bar.as_ref().unwrap().on_action_page());
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
assert!(!bar.on_action_page());
assert_eq!(bar.query(), "alp");
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
// A second escape is the way out again.
app.handle_overlay_input(&InputKey::Esc);
assert!(app.command_bar.is_none());
assert!(app.state.overlay().is_none());
}
} }
+333 -18
View File
@@ -2,8 +2,9 @@
//! result list, opened from Normal or Insert mode, closed with Escape. //! result list, opened from Normal or Insert mode, closed with Escape.
//! //!
//! Three kinds live here: sessions and commands, ranked together by default, //! Three kinds live here: sessions and commands, ranked together by default,
//! and directories, which need the `~` sigil (#154). The session action page //! and directories, which need the `~` sigil (#154). `tab` on a session drills
//! (#155) and the leader's literal Ctrl-K (#156) land later. //! into its action page (#155), the same overlay over three actions. The
//! leader's literal Ctrl-K (#156) lands later.
//! //!
//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the //! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the
//! groups are a rendering of the ranked list, in the order their best-scoring //! groups are a rendering of the ranked list, in the order their best-scoring
@@ -46,8 +47,42 @@ pub enum BarTarget {
/// Fast-forward spawn in this directory, absolute — the abbreviated form /// Fast-forward spawn in this directory, absolute — the abbreviated form
/// is what the row shows and what the matcher ranked. /// is what the row shows and what the matcher ranked.
Directory(PathBuf), Directory(PathBuf),
/// A row of the action page: run this action against this session (#155).
SessionAction {
session: Uuid,
action: SessionActionId,
},
} }
/// 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"). Mirrors the web's `SessionActionId`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionActionId {
Kill,
Rename,
SpawnSimilar,
}
impl SessionActionId {
/// The row's text, which is also the only field the matcher sees.
pub fn label(self) -> &'static str {
match self {
Self::Kill => "Kill session",
Self::Rename => "Rename session",
Self::SpawnSimilar => "Spawn similar",
}
}
}
/// The page's actions in the order an empty query lists them, since the
/// matcher keeps input order.
pub const SESSION_ACTIONS: [SessionActionId; 3] = [
SessionActionId::Kill,
SessionActionId::Rename,
SessionActionId::SpawnSimilar,
];
/// The first character of the query, when it is one of the two that narrow the /// The first character of the query, when it is one of the two that narrow the
/// list to a single kind. /// list to a single kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -159,7 +194,9 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect(); let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
// (#162) — sorts after the sessions you could actually attach to.
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
dead(a) dead(a)
.cmp(&dead(b)) .cmp(&dead(b))
.then_with(|| { .then_with(|| {
@@ -255,10 +292,23 @@ pub struct CommandBar {
sessions: Vec<BarEntry>, sessions: Vec<BarEntry>,
commands: Vec<BarEntry>, commands: Vec<BarEntry>,
directories: Vec<BarEntry>, directories: Vec<BarEntry>,
/// The action page while it is up (#155), and the result-list query it was
/// drilled into from — backing out restores that query, so the round trip
/// costs the operator nothing.
page: Option<ActionPage>,
stashed: String,
rows: Vec<BarRow>, rows: Vec<BarRow>,
highlight: usize, highlight: usize,
} }
/// The session action page while it is up: the name the overlay titles itself
/// with, and the three action rows the query line runs over.
#[derive(Debug)]
struct ActionPage {
name: String,
actions: Vec<BarEntry>,
}
impl CommandBar { impl CommandBar {
/// Open the bar over the sessions the catalog has right now and the live /// Open the bar over the sessions the catalog has right now and the live
/// keymap for the current mode. Directories arrive later /// keymap for the current mode. Directories arrive later
@@ -271,7 +321,7 @@ impl CommandBar {
.map(|s| BarEntry { .map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)), candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id), target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead, dead: s.status != SessionStatus::Running,
chords: Vec::new(), chords: Vec::new(),
}) })
.collect(); .collect();
@@ -293,6 +343,8 @@ impl CommandBar {
sessions, sessions,
commands, commands,
directories: Vec::new(), directories: Vec::new(),
page: None,
stashed: String::new(),
rows: Vec::new(), rows: Vec::new(),
highlight: 0, highlight: 0,
}; };
@@ -381,7 +433,15 @@ impl CommandBar {
/// "Keys") does not claim is ordinary editing. /// "Keys") does not claim is ordinary editing.
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
match key { match key {
InputKey::Esc => BarOutcome::Close, // On the action page Escape is one level up, not the way out.
InputKey::Esc => {
if self.page.is_some() {
self.leave_page();
BarOutcome::Stay
} else {
BarOutcome::Close
}
}
InputKey::Enter => match self.selected() { InputKey::Enter => match self.selected() {
Some(row) => BarOutcome::Open(row.target.clone()), Some(row) => BarOutcome::Open(row.target.clone()),
// Nothing to open: the bar stays up rather than closing on a // Nothing to open: the bar stays up rather than closing on a
@@ -402,9 +462,19 @@ impl CommandBar {
self.move_highlight(1); self.move_highlight(1);
BarOutcome::Stay BarOutcome::Stay
} }
// Tab drills into the highlighted session's action page (#155). // Tab drills into the highlighted session's action page (#155);
// Until then it is inert rather than a literal tab in the query. // shift-tab is inert rather than a literal tab in the query.
InputKey::Tab | InputKey::BackTab => BarOutcome::Stay, InputKey::Tab => {
self.drill_in();
BarOutcome::Stay
}
InputKey::BackTab => BarOutcome::Stay,
// Ordinary editing, except at an empty query on the action page,
// where it is the second way back to the result list.
InputKey::Backspace if self.page.is_some() && self.input.value().is_empty() => {
self.leave_page();
BarOutcome::Stay
}
other => { other => {
let before = self.input.value().to_string(); let before = self.input.value().to_string();
self.input.handle_key(other); self.input.handle_key(other);
@@ -416,15 +486,75 @@ impl CommandBar {
} }
} }
/// `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.
fn drill_in(&mut self) {
if self.page.is_some() {
return;
}
let Some(row) = self.selected() else {
return;
};
let BarTarget::Session(session) = row.target else {
return;
};
let name = row.name.clone();
let actions = SESSION_ACTIONS
.iter()
.map(|&action| BarEntry {
candidate: MatchCandidate::Command(CommandCandidate {
label: action.label().to_string(),
}),
target: BarTarget::SessionAction { session, action },
dead: false,
chords: Vec::new(),
})
.collect();
self.page = Some(ActionPage { name, actions });
self.stashed = self.input.value().to_string();
self.input = TextInput::default();
self.recompute();
}
/// Escape, or backspace at an empty query: back to the result list with
/// the query the drill-in was made from.
fn leave_page(&mut self) {
self.page = None;
self.input = TextInput::with_value(std::mem::take(&mut self.stashed));
self.recompute();
}
/// Whether the action page is up. The session it acts on is in every one
/// of its rows' targets.
pub fn on_action_page(&self) -> bool {
self.page.is_some()
}
/// What the matcher sees. On the action page the whole query is the query:
/// a sigil narrows the result list to one kind, and the page is one kind
/// already.
fn parsed_query(&self) -> BarQuery<'_> {
match self.page {
Some(_) => BarQuery {
sigil: None,
query: self.input.value(),
},
None => parse_bar_query(self.input.value()),
}
}
/// The sigil in force for the query as it stands. /// The sigil in force for the query as it stands.
pub fn sigil(&self) -> Option<BarSigil> { pub fn sigil(&self) -> Option<BarSigil> {
parse_bar_query(self.input.value()).sigil self.parsed_query().sigil
} }
/// The candidates the query line asks for. A sigil narrows to one kind; /// The candidates the query line asks for. A sigil narrows to one kind;
/// with no sigil the list is mixed — sessions and commands ranked together /// with no sigil the list is mixed — sessions and commands ranked together
/// — except on an empty query, where the bar is a session switcher. /// — except on an empty query, where the bar is a session switcher.
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> { fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
if let Some(page) = &self.page {
return page.actions.iter().collect();
}
match parsed.sigil { match parsed.sigil {
Some(BarSigil::Command) => self.commands.iter().collect(), Some(BarSigil::Command) => self.commands.iter().collect(),
Some(BarSigil::Directory) => self.directories.iter().collect(), Some(BarSigil::Directory) => self.directories.iter().collect(),
@@ -438,7 +568,7 @@ impl CommandBar {
/// different result. /// different result.
fn recompute(&mut self) { fn recompute(&mut self) {
let rows: Vec<BarRow> = { let rows: Vec<BarRow> = {
let parsed = parse_bar_query(self.input.value()); let parsed = self.parsed_query();
let entries = self.active_entries(parsed); let entries = self.active_entries(parsed);
let candidates: Vec<MatchCandidate> = let candidates: Vec<MatchCandidate> =
entries.iter().map(|e| e.candidate.clone()).collect(); entries.iter().map(|e| e.candidate.clone()).collect();
@@ -482,12 +612,16 @@ impl CommandBar {
let height = parent.height.saturating_sub(4).clamp(5, 20); let height = parent.height.saturating_sub(4).clamp(5, 20);
let area = centered(parent, 72, height); let area = centered(parent, 72, height);
f.render_widget(Clear, area); f.render_widget(Clear, area);
// The page names the session being acted on, so the target is never
// ambiguous.
let title = match (&self.page, self.sigil()) {
(Some(page), _) => format!(" act on {} ", page.name),
(None, Some(BarSigil::Command)) => " run a command ".to_string(),
(None, Some(BarSigil::Directory)) => " spawn in ".to_string(),
(None, None) => " go to ".to_string(),
};
let block = Block::default() let block = Block::default()
.title(match self.sigil() { .title(title)
Some(BarSigil::Command) => " run a command ",
Some(BarSigil::Directory) => " spawn in ",
None => " go to ",
})
.borders(Borders::ALL) .borders(Borders::ALL)
.style(pal.card.base()) .style(pal.card.base())
.border_style(pal.border_style(true)); .border_style(pal.border_style(true));
@@ -510,7 +644,11 @@ impl CommandBar {
.render(query, f.buffer_mut(), pal.card.text_style()); .render(query, f.buffer_mut(), pal.card.text_style());
f.render_widget( f.render_widget(
Paragraph::new(Line::from(Span::styled( Paragraph::new(Line::from(Span::styled(
"[enter] open [esc] close", if self.page.is_some() {
"[enter] run [esc] back"
} else {
"[enter] open [esc] close"
},
pal.card.dim_style(), pal.card.dim_style(),
))), ))),
row(inner.height - 1), row(inner.height - 1),
@@ -540,8 +678,9 @@ impl CommandBar {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
let mut highlight_line = 0usize; let mut highlight_line = 0usize;
// While a sigil is in force there is one kind, so the header is // While a sigil is in force there is one kind, so the header is
// dropped — it would name what the whole list already is. // dropped — it would name what the whole list already is. The action
let headers = self.sigil().is_none(); // page is one kind too.
let headers = self.sigil().is_none() && self.page.is_none();
for group in self.groups() { for group in self.groups() {
if headers { if headers {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
@@ -752,6 +891,23 @@ mod tests {
assert!(bar.rows()[1].dead); assert!(bar.rows()[1].dead);
} }
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
/// which way the pane went away — a dormant session sorts with the dead.
#[test]
fn dormant_sessions_sort_after_live_ones_however_recent() {
let mut dormant = named(1, "dormant");
dormant.status = SessionStatus::Dormant;
dormant.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
);
assert!(bar.rows()[1].dead);
}
#[test] #[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() { fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new( let mut bar = CommandBar::new(
@@ -1219,4 +1375,163 @@ mod tests {
} }
assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}");
} }
// ---- the session action page (#155) ----
fn targets(rows: &[BarRow]) -> Vec<BarTarget> {
rows.iter().map(|r| r.target.clone()).collect()
}
#[test]
fn tab_on_a_session_opens_its_action_page() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
assert!(bar.on_action_page());
assert_eq!(
labels(bar.rows()),
vec!["Kill session", "Rename session", "Spawn similar"]
);
assert_eq!(
targets(bar.rows()),
SESSION_ACTIONS
.iter()
.map(|&action| BarTarget::SessionAction {
session: Uuid::from_u128(1),
action,
})
.collect::<Vec<_>>()
);
}
/// Tab has no effect on any other kind, and none on the page itself, which
/// has no session rows.
#[test]
fn tab_on_a_command_or_directory_row_does_nothing() {
let keymap = vec![("x".to_string(), Action::KillHighlighted)];
let mut bar = CommandBar::new(&[named(1, "alpha")], &keymap, "/home/me");
typed(&mut bar, ">");
bar.handle_key(&InputKey::Tab);
assert!(!bar.on_action_page());
assert_eq!(bar.query(), ">");
let mut bar = CommandBar::new(&[], &[], "/home/me");
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
typed(&mut bar, "~");
bar.handle_key(&InputKey::Tab);
assert!(!bar.on_action_page());
}
#[test]
fn tab_on_the_action_page_does_not_drill_further() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
bar.handle_key(&InputKey::Tab);
assert!(bar.on_action_page());
assert_eq!(labels(bar.rows()).len(), 3);
}
/// The page drills into the highlighted session, not the first one.
#[test]
fn the_page_acts_on_the_highlighted_session() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
bar.move_highlight(1);
bar.handle_key(&InputKey::Tab);
assert_eq!(
bar.rows()[0].target,
BarTarget::SessionAction {
session: Uuid::from_u128(2),
action: SessionActionId::Kill,
}
);
}
#[test]
fn the_action_page_stays_fuzzy_searchable() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
typed(&mut bar, "ren");
assert_eq!(labels(bar.rows()), vec!["Rename session"]);
assert_eq!(
bar.selected().map(|r| r.target.clone()),
Some(BarTarget::SessionAction {
session: Uuid::from_u128(1),
action: SessionActionId::Rename,
})
);
}
/// A sigil narrows the result list to one kind; the page is one kind
/// already, so `>` there is a query character like any other.
#[test]
fn a_sigil_is_an_ordinary_character_on_the_action_page() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
typed(&mut bar, ">");
assert_eq!(bar.sigil(), None);
assert!(bar.rows().is_empty());
}
#[test]
fn escape_returns_to_the_result_list_with_the_query_intact() {
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
typed(&mut bar, "alp");
assert_eq!(bar.handle_key(&InputKey::Tab), BarOutcome::Stay);
assert_eq!(bar.query(), "", "the page starts on an empty query");
// Escape is one level up, not the way out.
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Stay);
assert!(!bar.on_action_page());
assert_eq!(bar.query(), "alp");
assert_eq!(labels(bar.rows()), vec!["alpha"]);
// Off the page it closes the bar again.
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close);
}
#[test]
fn backspace_at_an_empty_query_returns_to_the_result_list() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "alp");
bar.handle_key(&InputKey::Tab);
bar.handle_key(&InputKey::Backspace);
assert!(!bar.on_action_page());
assert_eq!(bar.query(), "alp");
}
/// Backspace with something typed is ordinary editing of the page's own
/// query, not the way out.
#[test]
fn backspace_on_a_non_empty_page_query_edits_it() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
typed(&mut bar, "alp");
bar.handle_key(&InputKey::Tab);
typed(&mut bar, "kx");
bar.handle_key(&InputKey::Backspace);
assert!(bar.on_action_page());
assert_eq!(bar.query(), "k");
assert_eq!(labels(bar.rows()), vec!["Kill session"]);
}
#[test]
fn enter_on_the_page_opens_the_action_for_that_session() {
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
typed(&mut bar, "spawn");
assert_eq!(
bar.handle_key(&InputKey::Enter),
BarOutcome::Open(BarTarget::SessionAction {
session: Uuid::from_u128(1),
action: SessionActionId::SpawnSimilar,
})
);
}
#[test]
fn the_action_page_titles_itself_with_the_session_it_acts_on() {
let mut bar = CommandBar::new(&[named(1, "amber-falcon")], &[], "/home/me");
bar.handle_key(&InputKey::Tab);
let rendered = render_to_string(&bar, 90, 20);
assert!(rendered.contains("act on amber-falcon"), "{rendered}");
assert!(rendered.contains("Kill session"), "{rendered}");
// One kind, so the group header is dropped the way a sigil drops it.
assert!(!rendered.contains("Commands"), "{rendered}");
}
} }
+74 -1
View File
@@ -114,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));
@@ -146,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 &section.keymap_normal { for (input, action) in &section.keymap_normal {
keymap.set(Mode::Normal, input, action).map_err(|source| { keymap.set(Mode::Normal, input, action).map_err(|source| {
@@ -278,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(&section).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(&section).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();
+1
View File
@@ -250,6 +250,7 @@ mod tests {
dirty: false, dirty: false,
}, },
instance: instance.into(), instance: instance.into(),
leader: maestro_config::DEFAULT_LEADER.into(),
} }
} }
+72
View File
@@ -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
@@ -1679,6 +1705,7 @@ mod tests {
id: "aider".into(), id: "aider".into(),
label: "Aider".into(), label: "Aider".into(),
command: "aider".into(), command: "aider".into(),
resume_command: None,
}]); }]);
assert_eq!( assert_eq!(
draft.command(), draft.command(),
@@ -1688,6 +1715,51 @@ 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(),
resume_command: None,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
resume_command: None,
},
]);
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(),
resume_command: None,
}]);
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 {
+1
View File
@@ -183,6 +183,7 @@ mod tests {
id: id.into(), id: id.into(),
label: id.into(), label: id.into(),
command: command.into(), command: command.into(),
resume_command: None,
} }
} }
+70 -4
View File
@@ -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 {
self.held.clear();
ChordResult::Resolved(candidate)
} }
if known_bindings.contains(&candidate) {
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)
}
/// 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()
} }
} }
@@ -733,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));
+39
View File
@@ -268,6 +268,8 @@ impl Sidebar {
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) => "○",
}; };
@@ -322,6 +324,7 @@ 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 fill = self.card_fill(s, focused); let fill = self.card_fill(s, focused);
let base = card_base(fill, focused); let base = card_base(fill, focused);
@@ -361,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);
@@ -1068,6 +1074,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() {
+24 -11
View File
@@ -45,8 +45,21 @@ 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 `preventDefault`s it, since selecting all of a terminal pane does nothing
useful. useful.
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
none of them needs one. `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 ### Focus round-trip
@@ -110,9 +123,9 @@ 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. `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**, **A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live and among those, most recently created first. Sessions with no live pane —
ones regardless of recency: a session you cannot attach to is not where you `Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
just were. session you cannot attach to is not where you just were.
Under `>` an empty query lists every command in registry order. Under `~` it Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list. lists recent dirs, most recent first, then the project list.
@@ -145,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
ambiguous. ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the - Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free. previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing - Each action raises the flow it already has: kill through the confirmation
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
existing prompt pre-filled with the session's own name (`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
session's directory and command. The bar picks the action; it never seeded from the session's directory and command. The bar picks the action; it
reimplements one. never reimplements one.
No actions beyond those three, and no action across more than one session. No actions beyond those three, and no action across more than one session.
+2 -1
View File
@@ -136,7 +136,8 @@ 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]
+1 -1
View File
@@ -46,7 +46,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. | | The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
| The canvas | A `preview` chip, top right, and a dashed inset ring. | | The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. | | A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. | | The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. | | A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in Rows say it with fill, not with an edge — see the tier ladder in
+20
View File
@@ -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
+27 -6
View File
@@ -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
@@ -189,7 +195,7 @@ furthest down wins the paint.
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard | | Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) | | Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface | | Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) | | Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in `styles.css`, every qualifier inside `:where()`, so the order they appear in
@@ -209,7 +215,7 @@ 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 highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to. step up to.
The duration text inherits its state's colour; the exited and command 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.
@@ -316,7 +322,7 @@ Everything else is the same key on both clients:
| `Tab` | hand the keyboard to the pane | | `Tab` | hand the keyboard to the pane |
| `c` | collapse or expand the highlighted folder | | `c` | collapse or expand the highlighted folder |
| `r` | rename the highlighted session | | `r` | rename the highlighted session |
| `x` | kill the highlighted session | | `x` | kill the highlighted session (confirms) |
| `n` | open the spawn form | | `n` | open the spawn form |
| `N` | spawn with every default resolved | | `N` | spawn with every default resolved |
| `?` | show the shortcut overlay | | `?` | show the shortcut overlay |
@@ -324,3 +330,18 @@ Everything else is the same key on both clients:
The TUI binds these through its keymap, so a user can rebind any of them; the 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 web client's are fixed. A binding added to one client belongs in this table and
in both help overlays. 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.
+9 -1
View File
@@ -65,7 +65,7 @@ async function flush(): Promise<void> {
} }
describe("kill from the action page", () => { describe("kill from the action page", () => {
it("goes through the daemon, without a first confirmation", async () => { it("goes through the same confirmation the sidebar raises (#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}`);
@@ -75,6 +75,12 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
await flush(); 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(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull(); expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
@@ -101,6 +107,8 @@ describe("kill from the action page", () => {
mount(s); mount(s);
raise(KILL_SESSION_EVENT, s); raise(KILL_SESSION_EVENT, s);
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?.getAttribute("aria-label")).toBe("Kill amber-falcon"); expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("1 untracked file");
+53 -1
View File
@@ -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",
+71 -41
View File
@@ -1,6 +1,6 @@
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 { toggleHelp } from "./help.js"; import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js"; import { CommandSet, defineCommands } from "./commands.js";
@@ -39,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. */
@@ -104,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);
@@ -118,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>`;
} }
@@ -221,7 +234,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void requestKill(cur, sessionName(cur)); requestKill(cur, sessionName(cur));
}); });
row.addEventListener("click", () => { row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
@@ -239,7 +252,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{ {
label: "Kill session", label: "Kill session",
danger: true, danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)), onSelect: () => requestKill(cur, sessionName(cur)),
}, },
]); ]);
}); });
@@ -284,26 +297,33 @@ function treeSignature(node: TreeNode): string {
} }
/** /**
* Kill on the first click (#81): the daemon is the one that decides whether a * Kill behind a confirmation (#160): a kill is destructive and unattended
* session may go, and it refuses over a worktree holding unsaved work. Only * agents live in these sessions, so the web asks first, the way the TUI always
* that refusal is worth a prompt, and it needs one `confirm()` cannot give — * has. The daemon decides the rest — it refuses a kill over a worktree holding
* a forced kill. Anything else surfaces as an alert. * unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/ */
async function requestKill(s: Session, name: string): Promise<void> { function requestKill(s: Session, name: string): void {
try { openKillConfirmDialog({
await killSession(s.id); name,
} catch (e) { blockers: sessionBlockers(s),
if (e instanceof ApiRequestError && e.code === "kill_guarded") { onKill: async () => {
openForceKillDialog({ try {
name, await killSession(s.id);
blockers: e.unsavedWork ?? sessionBlockers(s), } catch (e) {
reason: e.detail, if (e instanceof ApiRequestError && e.code === "kill_guarded") {
onForce: () => killSession(s.id, true), openForceKillDialog({
}); name,
return; blockers: e.unsavedWork ?? sessionBlockers(s),
} reason: e.detail,
alert(`Failed to kill: ${(e as Error).message}`); onForce: () => killSession(s.id, true),
} });
return;
}
throw e;
}
},
});
} }
/** /**
@@ -361,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>
@@ -441,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"
@@ -555,11 +575,23 @@ 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 };
@@ -698,7 +730,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
}, },
"list.kill": () => { "list.kill": () => {
const hit = highlightedSession(); const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session)); if (hit) requestKill(hit.session, sessionName(hit.session));
}, },
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)), "list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}), }),
@@ -716,8 +748,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
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:${filter.trim()}` : treeSignature(tree); const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
if (signature === renderedSignature) { if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) { for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined; const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
@@ -728,9 +760,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
renderedSignature = signature; 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") {
@@ -763,7 +793,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// than by row. // than by row.
const killHandler = (ev: Event) => { const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
void requestKill(s, sessionName(s)); requestKill(s, sessionName(s));
}; };
const renameHandler = (ev: Event) => { const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
+14
View File
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
]); ]);
}); });
// #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", () => { it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null }); const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+5 -3
View File
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */ * matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] { export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => { return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0; // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
const deadB = b.status === "Dead" ? 1 : 0; // — 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; if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a); const focusA = focusedAt(a);
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind; row.dataset.kind = candidate.kind;
if (candidate.kind === "session") { if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id; row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead"); if (candidate.session.status !== "Running") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) { } else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id; row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") { } else if (candidate.kind === "command") {
+93
View File
@@ -4,14 +4,17 @@ import { describe, expect, it, vi } from "vitest";
import { import {
COMMAND_SPECS, COMMAND_SPECS,
CommandSet, CommandSet,
commandSpecs,
DEFAULT_LEADER, DEFAULT_LEADER,
canonicalizeKey, canonicalizeKey,
controlByte,
defineCommands, defineCommands,
isBarTypingTarget, isBarTypingTarget,
isTerminalTextarea, isTerminalTextarea,
isTypingTarget, isTypingTarget,
keyCaps, keyCaps,
publishedCommands, publishedCommands,
type Command,
} from "./commands.js"; } from "./commands.js";
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent { function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
@@ -163,6 +166,37 @@ describe("the leader", () => {
expect(DEFAULT_LEADER).toBe("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", () => { it("holds the leader as a partial chord instead of selecting the page", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
leader: DEFAULT_LEADER, leader: DEFAULT_LEADER,
@@ -307,3 +341,62 @@ describe("ctrl-k over a focused pane (#152)", () => {
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); 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();
});
});
+47 -9
View File
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
run: () => void; run: () => void;
} }
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all /** Leader used when the daemon does not name one — an older daemon, or an
* in a browser, so a set that owns the leader `preventDefault`s it. */ * 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"; export const DEFAULT_LEADER = "ctrl-a";
/** Every command the web client has. The single list this issue exists to /** Every command the web client has, for one leader. The single list: labels,
* create: labels, chords and grouping live here and nowhere else. */ * chords and grouping live here and nowhere else. Chords behind the leader are
export const COMMAND_SPECS: readonly CommandSpec[] = [ * 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", id: "spawn.open",
label: "Open the spawn form", label: "Open the spawn form",
@@ -65,6 +78,15 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
label: "Send a literal Ctrl-K to the pane", label: "Send a literal Ctrl-K to the pane",
group: "Pane", group: "Pane",
keys: [`${DEFAULT_LEADER} ctrl-k`], 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", scope: "global",
}, },
{ {
@@ -153,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
}, },
]; ];
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); /** 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 /** 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. */ * test failure rather than a key that silently does nothing. `leader` picks
export function defineCommands(handlers: Record<string, () => void>): Command[] { * 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]) => { return Object.entries(handlers).map(([id, run]) => {
const spec = SPEC_BY_ID.get(id); const spec = byId.get(id);
if (!spec) throw new Error(`unknown command id: ${id}`); if (!spec) throw new Error(`unknown command id: ${id}`);
return { ...spec, run }; return { ...spec, run };
}); });
@@ -227,6 +256,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
return out + e.key.toLowerCase(); 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)-/; const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
/** True when a chord carries a modifier or more than one key. Plain single-key /** True when a chord carries a modifier or more than one key. Plain single-key
+28 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { keyCaps, publishedCommands } from "./commands.js"; import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => { beforeEach(() => {
@@ -44,6 +44,33 @@ describe("help overlay", () => {
.toEqual(publishedCommands().map((c) => c.id)); .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 ?", () => {
press("?", { shiftKey: true }); press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true); expect(isHelpOpen()).toBe(true);
+83 -22
View File
@@ -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,
});
}
+69 -35
View File
@@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js"; import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen } from "./help.js"; import { installHelpKeybind, isHelpOpen } from "./help.js";
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js"; import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.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";
@@ -36,6 +36,7 @@ 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,
@@ -86,9 +87,15 @@ const store = new CatalogStore();
// Absolute paths are a fine fallback until it lands. // Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined; let homeDir: string | undefined;
void getDaemonInfo() void getDaemonInfo()
.then((info) => (homeDir = info.home_dir)) .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(() => { .catch(() => {
/* absolute paths are a fine fallback */ /* 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
@@ -438,41 +445,68 @@ const spawnKeys = new CommandSet(
// 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.
// //
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// key as a partial chord and takes select-all off it, so the escape hatch // on `/api/info`): it holds the key as a partial chord and takes select-all off
// docs/COMMAND-BAR.md describes has somewhere to land (#149). // it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
const focusKeys = new CommandSet( // (#149).
defineCommands({ let currentLeader = DEFAULT_LEADER;
"focus.toggle": () => {
if (sidebar.hasListFocus()) { function buildFocusKeys(leader: string): CommandSet {
pool.focusTerminal(); return new CommandSet(
} else { defineCommands(
drawer.open(); // no-op on desktop; on mobile the list is off-canvas {
sidebar.focusList(); "focus.toggle": () => {
} if (sidebar.hasListFocus()) {
}, pool.focusTerminal();
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line } else {
// away from every pane, and leader ctrl-k gives it back as the literal drawer.open(); // no-op on desktop; on mobile the list is off-canvas
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the sidebar.focusList();
// bar's, so the chord never opens the bar. }
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"), },
}), // The escape hatch: binding ctrl-k takes readline's
{ leader: DEFAULT_LEADER }, // 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 // 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. // like any other, so the `?` overlay lists it without a second list.
const barKeys = new CommandSet( const openBar = () => {
defineCommands({ // Opening while a session is previewed discards the preview first, like
"bar.open": () => { // any other real change (docs/PREVIEW.md): the bar is not previewed into.
// Opening while a session is previewed discards the preview first, like schedulePreview.cancel();
// any other real change (docs/PREVIEW.md): the bar is not previewed into. mv?.previewSession(null);
schedulePreview.cancel(); openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
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 // 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). // bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
+3 -1
View File
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}); });
it("x kills the highlighted session", async () => { it("x kills the highlighted session, once confirmed (#160)", async () => {
const { el } = twoFolders(); const { el } = twoFolders();
const killed: string[] = []; const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j"); press(tree(el), "j");
press(tree(el), "x"); press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await Promise.resolve(); await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true); expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
}); });
+135
View File
@@ -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);
});
});
+21 -4
View File
@@ -1202,8 +1202,10 @@ textarea {
} }
/* 5. The row the content pane is previewing right now. Last, so previewing a /* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional. */ session that is already open still reads as provisional — except the session
.tree-leaf:where(.is-previewing) { already mounted in the pane (tier 4). Previewing that one changes nothing on
screen, so its accent stays put instead of blinking out under the pointer. */
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
background: var(--preview-tint); background: var(--preview-tint);
} }
@@ -1558,7 +1560,10 @@ input.leaf-rename:focus {
} }
/* Phone: the bottom-sheet treatment the spawn modal already uses. */ /* Phone: the bottom-sheet treatment the spawn modal already uses. */
@media (max-width: 600px) { /* 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 { .cmdbar-overlay {
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
padding: 0; padding: 0;
@@ -1567,9 +1572,21 @@ input.leaf-rename:focus {
.cmdbar-panel { .cmdbar-panel {
grid-row: 2; grid-row: 2;
width: 100%; width: 100%;
max-height: 70vh; max-height: 70dvh;
border-radius: var(--r-3) var(--r-3) 0 0; border-radius: var(--r-3) var(--r-3) 0 0;
border-bottom: 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;
} }
} }
+2 -26
View File
@@ -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
View File
@@ -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) {
+8 -1
View File
@@ -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
@@ -98,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