Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9728b10ffd | ||
|
|
1b2b392710 | ||
|
|
72644dd564 | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 | ||
|
|
59ede15d2b | ||
|
|
331039df11 | ||
|
|
151438094e | ||
|
|
c1c283c0f8 | ||
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe | ||
|
|
f8b434dd1d |
@@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let status = match s.status {
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
SessionStatus::Dormant => "dormant",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
resume_command: None,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
||||
/// 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";
|
||||
|
||||
/// 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)]
|
||||
pub enum ConfigError {
|
||||
#[error("io: {0}")]
|
||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TuiTheme {
|
||||
@@ -299,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
resume_command: Some("claude --resume {agent_session}".into()),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
resume_command: None,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
resume_command: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -320,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
resume_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +511,18 @@ auto_install = false
|
||||
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]
|
||||
fn spawn_presets_override_replaces_builtins() {
|
||||
// Present list replaces the built-ins entirely; custom still appended.
|
||||
@@ -501,6 +531,7 @@ auto_install = false
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
resume_command: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -537,6 +568,7 @@ auto_install = false
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
resume_command: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -547,6 +579,7 @@ auto_install = false
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
resume_command: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -564,11 +597,13 @@ auto_install = false
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
resume_command: None,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
resume_command: None,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
@@ -583,6 +618,7 @@ auto_install = false
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
resume_command: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -618,6 +654,27 @@ presets = [
|
||||
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]
|
||||
fn spawn_project_scan_fields_default_to_none() {
|
||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||
|
||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
||||
) -> Result<(), axum::Error> {
|
||||
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
|
||||
// screenful immediately instead of a blank terminal. The replay lock the
|
||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||
// 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() {
|
||||
manager.release_viewport(id, viewport).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||
match ctrl {
|
||||
ClientControl::Resize { cols, rows } => {
|
||||
let _ = manager.resize(id, cols, rows).await;
|
||||
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||
}
|
||||
ClientControl::Ping => {
|
||||
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 {
|
||||
let _ = sink.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
config.leader.clone(),
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||
/// `true`.
|
||||
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 {
|
||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
leader: file.tui.leader().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
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]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod spawn_history;
|
||||
#[cfg(feature = "web")]
|
||||
pub mod static_bundle;
|
||||
pub mod tmux;
|
||||
pub mod viewport;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct LocalApiState {
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
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 {
|
||||
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
presets: (*s.presets).clone(),
|
||||
build: crate::release::build_info(),
|
||||
instance: crate::release::instance_id().to_string(),
|
||||
leader: (*s.leader).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -374,6 +379,7 @@ pub fn state_for(
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
leader: impl Into<String>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
@@ -381,6 +387,7 @@ pub fn state_for(
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
leader: Arc::new(leader.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
|
||||
use crate::sidecar::{SidecarError, SidecarStore};
|
||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||
use crate::tmux::TmuxError;
|
||||
use crate::viewport::{ViewportId, Viewports};
|
||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||
|
||||
/// 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
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
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
|
||||
/// outranks the activity pass's own inference; when it lapses, inference
|
||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||
@@ -166,6 +171,14 @@ struct Inner {
|
||||
report_ttl: Duration,
|
||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||
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 {
|
||||
@@ -206,6 +219,8 @@ impl SessionManager {
|
||||
report_ttl: crate::activity::REPORT_TTL,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
viewports: Mutex::new(Viewports::new()),
|
||||
next_viewport: AtomicU64::new(0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -279,7 +294,10 @@ impl SessionManager {
|
||||
/// unknown to this daemon.
|
||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||
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
|
||||
@@ -298,19 +316,26 @@ impl SessionManager {
|
||||
let (result, updated) = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id)?;
|
||||
rec.session.last_focused_at = Some(Utc::now());
|
||||
let updated = rec.session.clone();
|
||||
let SessionRecord {
|
||||
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
|
||||
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
||||
// replayed from it renders as garbage until something forces a
|
||||
// repaint. A dead session has no pane left to capture, so it
|
||||
// keeps the byte tail.
|
||||
let alive = rec.session.status == SessionStatus::Running;
|
||||
let alive = session.status == SessionStatus::Running;
|
||||
// The capture shells out to tmux while the replay lock is held,
|
||||
// which parks the reader for the length of one tmux round-trip.
|
||||
// That only happens on attach, and the alternative is a seam in
|
||||
// the stream.
|
||||
let result = rec.bridge.subscribe_with_snapshot(|| {
|
||||
let result = bridge.subscribe_with_snapshot(|| {
|
||||
if !alive {
|
||||
return None;
|
||||
}
|
||||
@@ -487,7 +512,7 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
bridge,
|
||||
bridge: Some(bridge),
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
@@ -695,7 +720,7 @@ impl SessionManager {
|
||||
// directory blocks that branch forever (#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,
|
||||
bridge: Some(bridge),
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
@@ -710,16 +735,89 @@ impl SessionManager {
|
||||
.send(CatalogEvent::SessionAdded { session });
|
||||
adopted += 1;
|
||||
}
|
||||
self.register_dormant().await;
|
||||
Ok(adopted)
|
||||
}
|
||||
|
||||
/// Forward keystrokes to a session's pane. A dead 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.
|
||||
/// Second recovery pass (#162): register every sidecar tmux had nothing to
|
||||
/// adopt as a [`SessionStatus::Dormant`] session.
|
||||
///
|
||||
/// 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> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
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
|
||||
/// `submit` is set.
|
||||
///
|
||||
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||
/// asked for text to land somewhere, and a silent success would let a
|
||||
/// script believe an agent had been told something it never heard.
|
||||
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
|
||||
/// caller asked for text to land somewhere, and a silent success would let
|
||||
/// 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> {
|
||||
match self.status(id).await {
|
||||
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) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -756,12 +856,12 @@ impl SessionManager {
|
||||
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`].
|
||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -770,6 +870,52 @@ impl SessionManager {
|
||||
.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
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
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
|
||||
// nothing here may overwrite it; only an unreported session is
|
||||
// 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
|
||||
/// (#81), which leaves it on disk too.
|
||||
///
|
||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||
/// adopts only what tmux still holds.
|
||||
/// A dead session does not come back as dead across a daemon restart: its
|
||||
/// 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) {
|
||||
let flipped = {
|
||||
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;
|
||||
// Release anyone attached; the scrollback survives for a later
|
||||
// attach.
|
||||
record.bridge.seal();
|
||||
if let Some(bridge) = record.bridge.as_mut() {
|
||||
bridge.seal();
|
||||
}
|
||||
let session = record.session.clone();
|
||||
let worktree = record
|
||||
.worktree_base_repo
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! What size the shared tmux window should be, given every client watching it.
|
||||
//!
|
||||
//! A tmux window has one size, but a session can have several clients attached
|
||||
//! at once — two browser panes, a browser and the TUI. Each reports the size
|
||||
//! *it* can display, and applying whichever arrived last makes the window
|
||||
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
|
||||
//! repaint at a new height, and the rows it drew at the old height are never
|
||||
//! cleared: the operator ends up looking at a stale input box, types into a
|
||||
//! box that is not where the screen says it is, and concludes the pane is
|
||||
//! stuck.
|
||||
//!
|
||||
//! So the window is sized to the smallest attached client on each axis — the
|
||||
//! same rule tmux uses for its own multiple clients, and the only size every
|
||||
//! client can render without truncation. The size last handed to tmux is
|
||||
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One attached client within a session. Allocated by
|
||||
/// [`crate::session::SessionManager`] and opaque to everything else.
|
||||
pub type ViewportId = u64;
|
||||
|
||||
/// Per-session client sizes, and what tmux was last told.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Viewports {
|
||||
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
|
||||
applied: HashMap<Uuid, (u16, u16)>,
|
||||
}
|
||||
|
||||
impl Viewports {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record what `viewport` can display. Returns the size tmux must be set
|
||||
/// to, or `None` when the window already has it.
|
||||
pub fn set(
|
||||
&mut self,
|
||||
session: Uuid,
|
||||
viewport: ViewportId,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Option<(u16, u16)> {
|
||||
self.clients
|
||||
.entry(session)
|
||||
.or_default()
|
||||
.insert(viewport, (cols, rows));
|
||||
self.settle(session)
|
||||
}
|
||||
|
||||
/// Drop a client that has detached. Returns the size tmux must be set to
|
||||
/// now that it no longer constrains the window.
|
||||
///
|
||||
/// The last client leaving settles nothing: the window keeps the size it
|
||||
/// has rather than snapping to some remembered default no one is watching.
|
||||
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
|
||||
let clients = self.clients.get_mut(&session)?;
|
||||
clients.remove(&viewport);
|
||||
if clients.is_empty() {
|
||||
self.clients.remove(&session);
|
||||
self.applied.remove(&session);
|
||||
return None;
|
||||
}
|
||||
self.settle(session)
|
||||
}
|
||||
|
||||
/// The smallest size every attached client can render, but only when it
|
||||
/// differs from what tmux was last told.
|
||||
///
|
||||
/// A zero on either axis is a client that cannot display anything yet — a
|
||||
/// pane still laying out, a hidden tab — and must not drag the shared
|
||||
/// window down with it, so it is held but left out of the arbitration.
|
||||
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
|
||||
let clients = self.clients.get(&session)?;
|
||||
let mut cols = u16::MAX;
|
||||
let mut rows = u16::MAX;
|
||||
for &(c, r) in clients.values() {
|
||||
if c == 0 || r == 0 {
|
||||
continue;
|
||||
}
|
||||
cols = cols.min(c);
|
||||
rows = rows.min(r);
|
||||
}
|
||||
if cols == u16::MAX || rows == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let target = (cols, rows);
|
||||
if self.applied.get(&session) == Some(&target) {
|
||||
return None;
|
||||
}
|
||||
self.applied.insert(session, target);
|
||||
Some(target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_first_report_settles_the_window() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_that_changes_nothing_costs_no_resize() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||
assert_eq!(v.set(s, 1, 80, 24), None);
|
||||
}
|
||||
|
||||
/// The bug this module exists for: two clients of different sizes used to
|
||||
/// resize the window past each other on every report.
|
||||
#[test]
|
||||
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
|
||||
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
|
||||
// Whatever order they re-report in, the answer stays the smaller one.
|
||||
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||
assert_eq!(v.set(s, 2, 53, 20), None);
|
||||
assert_eq!(v.set(s, 1, 53, 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_axis_is_arbitrated_on_its_own() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 200, 20);
|
||||
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_sized_client_does_not_shrink_the_window() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
|
||||
assert_eq!(v.set(s, 2, 0, 0), None);
|
||||
assert_eq!(v.set(s, 2, 0, 30), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_small_client_gives_the_window_back() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 46);
|
||||
v.set(s, 2, 80, 20);
|
||||
assert_eq!(v.release(s, 2), Some((80, 46)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_last_client_settles_nothing() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.release(s, 1), None);
|
||||
// ...and the session is forgotten, so a fresh attach settles again.
|
||||
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_an_unknown_viewport_is_harmless() {
|
||||
let mut v = Viewports::new();
|
||||
let s = Uuid::new_v4();
|
||||
assert_eq!(v.release(s, 7), None);
|
||||
v.set(s, 1, 80, 24);
|
||||
assert_eq!(v.release(s, 7), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_do_not_arbitrate_against_each_other() {
|
||||
let mut v = Viewports::new();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
|
||||
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
|
||||
assert_eq!(v.set(a, 1, 80, 24), None);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
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 {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
@@ -48,6 +56,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
leader,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
|
||||
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]
|
||||
async fn info_reports_build_and_instance() {
|
||||
// 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())
|
||||
);
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// #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
|
||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||
|
||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
maestro_config::DEFAULT_LEADER,
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -123,10 +123,38 @@ fn epoch() -> DateTime<Utc> {
|
||||
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 {
|
||||
Running,
|
||||
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
|
||||
@@ -489,8 +517,11 @@ impl ApiError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpawnPreset {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub label: 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
|
||||
@@ -554,6 +585,12 @@ pub struct DaemonInfo {
|
||||
/// network blip, even when the build did not change.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -1053,6 +1090,33 @@ mod tests {
|
||||
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
||||
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]
|
||||
|
||||
@@ -56,6 +56,14 @@ pub enum Action {
|
||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||
/// over the sessions, ranked by the shared matcher.
|
||||
OpenCommandBar,
|
||||
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
|
||||
/// byte to the focused pane instead of opening the command bar, giving
|
||||
/// readline's kill-to-end-of-line back.
|
||||
SendLiteralCtrlK,
|
||||
/// Escape hatch (#158): the leader pressed twice sends one literal
|
||||
/// leader byte to the focused pane and drops the pending chord, the way
|
||||
/// tmux resolves a doubled prefix key.
|
||||
SendLiteralLeader,
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
@@ -98,6 +106,8 @@ impl Action {
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::OpenCommandBar => "Go to a session".into(),
|
||||
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||
Action::ShowHelp => "Show help".into(),
|
||||
Action::Quit => "Quit Maestro".into(),
|
||||
@@ -125,6 +135,8 @@ impl Action {
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
@@ -256,11 +268,26 @@ mod tests {
|
||||
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]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::OpenCommandBar,
|
||||
Action::SendLiteralCtrlK,
|
||||
Action::RenameHighlighted,
|
||||
Action::Quit,
|
||||
Action::OpenSpawnModal,
|
||||
|
||||
+347
-42
@@ -23,7 +23,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::action::Action;
|
||||
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::daemon_link::DaemonLink;
|
||||
use crate::draft::{DraftInputResult, DraftPane};
|
||||
@@ -306,6 +306,7 @@ impl App {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
self.chord.take_replay();
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -325,32 +326,46 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||
match self.chord.feed(&key, &known) {
|
||||
ChordResult::Pending => Ok(false),
|
||||
ChordResult::Resolved(canon) => {
|
||||
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
|
||||
if matches!(act, Some(Action::Quit)) {
|
||||
return Ok(true);
|
||||
// A key bumped out of an abandoned chord (#156) is replayed as its
|
||||
// own keystroke, against the mode dispatch may have just changed.
|
||||
let mut current_key = key;
|
||||
let mut result = self.chord.feed(
|
||||
¤t_key,
|
||||
&self.cfg.keymap.known_bindings(self.state.mode),
|
||||
);
|
||||
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(¤t_key).into_iter().collect();
|
||||
self.perform(effects).await;
|
||||
}
|
||||
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(&key).into_iter().collect();
|
||||
self.perform(effects).await;
|
||||
}
|
||||
Some(act) => {
|
||||
let effects = self.dispatch(act);
|
||||
self.perform(effects).await;
|
||||
}
|
||||
None => {}
|
||||
Some(act) => {
|
||||
let effects = self.dispatch(act);
|
||||
self.perform(effects).await;
|
||||
}
|
||||
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();
|
||||
(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) => {
|
||||
@@ -527,6 +561,16 @@ impl App {
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
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 => {
|
||||
self.open_spawn_modal();
|
||||
effects.push(Effect::LoadDraft);
|
||||
@@ -555,24 +599,12 @@ impl App {
|
||||
}
|
||||
Action::KillHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||
self.confirm_kill(sid);
|
||||
}
|
||||
}
|
||||
Action::RenameHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
// Pre-fill with the 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.
|
||||
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));
|
||||
self.prompt_rename(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,6 +890,30 @@ impl App {
|
||||
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
|
||||
/// project of the session the operator was last on — so `n` then Enter
|
||||
/// spawns another session there, the way "spawn similar" would. A
|
||||
@@ -871,6 +927,30 @@ impl App {
|
||||
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
|
||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||
/// 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))
|
||||
}
|
||||
|
||||
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
|
||||
/// it constantly, so no Insert-mode default may swallow it. The leader is
|
||||
/// 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
|
||||
/// 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]
|
||||
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
|
||||
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(
|
||||
§ion,
|
||||
key_event(
|
||||
crossterm::event::KeyCode::Char('a'),
|
||||
crossterm::event::KeyCode::Char('x'),
|
||||
crossterm::event::KeyModifiers::CONTROL,
|
||||
),
|
||||
)
|
||||
.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(§ion, &[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(§ion, &[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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! result list, opened from Normal or Insert mode, closed with Escape.
|
||||
//!
|
||||
//! Three kinds live here: sessions and commands, ranked together by default,
|
||||
//! and directories, which need the `~` sigil (#154). The session action page
|
||||
//! (#155) and the leader's literal Ctrl-K (#156) land later.
|
||||
//! and directories, which need the `~` sigil (#154). `tab` on a session drills
|
||||
//! 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
|
||||
//! 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
|
||||
/// is what the row shows and what the matcher ranked.
|
||||
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
|
||||
/// list to a single kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -159,7 +194,9 @@ pub enum BarOutcome {
|
||||
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
||||
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
||||
ordered.sort_by(|a, b| {
|
||||
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
|
||||
// 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)
|
||||
.cmp(&dead(b))
|
||||
.then_with(|| {
|
||||
@@ -255,10 +292,23 @@ pub struct CommandBar {
|
||||
sessions: Vec<BarEntry>,
|
||||
commands: 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>,
|
||||
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 {
|
||||
/// Open the bar over the sessions the catalog has right now and the live
|
||||
/// keymap for the current mode. Directories arrive later
|
||||
@@ -271,7 +321,7 @@ impl CommandBar {
|
||||
.map(|s| BarEntry {
|
||||
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
||||
target: BarTarget::Session(s.id),
|
||||
dead: s.status == SessionStatus::Dead,
|
||||
dead: s.status != SessionStatus::Running,
|
||||
chords: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
@@ -293,6 +343,8 @@ impl CommandBar {
|
||||
sessions,
|
||||
commands,
|
||||
directories: Vec::new(),
|
||||
page: None,
|
||||
stashed: String::new(),
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
};
|
||||
@@ -381,7 +433,15 @@ impl CommandBar {
|
||||
/// "Keys") does not claim is ordinary editing.
|
||||
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
||||
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() {
|
||||
Some(row) => BarOutcome::Open(row.target.clone()),
|
||||
// Nothing to open: the bar stays up rather than closing on a
|
||||
@@ -402,9 +462,19 @@ impl CommandBar {
|
||||
self.move_highlight(1);
|
||||
BarOutcome::Stay
|
||||
}
|
||||
// Tab drills into the highlighted session's action page (#155).
|
||||
// Until then it is inert rather than a literal tab in the query.
|
||||
InputKey::Tab | InputKey::BackTab => BarOutcome::Stay,
|
||||
// Tab drills into the highlighted session's action page (#155);
|
||||
// shift-tab is inert rather than a literal tab in the query.
|
||||
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 => {
|
||||
let before = self.input.value().to_string();
|
||||
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.
|
||||
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;
|
||||
/// with no sigil the list is mixed — sessions and commands ranked together
|
||||
/// — except on an empty query, where the bar is a session switcher.
|
||||
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
|
||||
if let Some(page) = &self.page {
|
||||
return page.actions.iter().collect();
|
||||
}
|
||||
match parsed.sigil {
|
||||
Some(BarSigil::Command) => self.commands.iter().collect(),
|
||||
Some(BarSigil::Directory) => self.directories.iter().collect(),
|
||||
@@ -438,7 +568,7 @@ impl CommandBar {
|
||||
/// different result.
|
||||
fn recompute(&mut self) {
|
||||
let rows: Vec<BarRow> = {
|
||||
let parsed = parse_bar_query(self.input.value());
|
||||
let parsed = self.parsed_query();
|
||||
let entries = self.active_entries(parsed);
|
||||
let candidates: Vec<MatchCandidate> =
|
||||
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 area = centered(parent, 72, height);
|
||||
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()
|
||||
.title(match self.sigil() {
|
||||
Some(BarSigil::Command) => " run a command ",
|
||||
Some(BarSigil::Directory) => " spawn in ",
|
||||
None => " go to ",
|
||||
})
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(pal.card.base())
|
||||
.border_style(pal.border_style(true));
|
||||
@@ -510,7 +644,11 @@ impl CommandBar {
|
||||
.render(query, f.buffer_mut(), pal.card.text_style());
|
||||
f.render_widget(
|
||||
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(),
|
||||
))),
|
||||
row(inner.height - 1),
|
||||
@@ -540,8 +678,9 @@ impl CommandBar {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut highlight_line = 0usize;
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — it would name what the whole list already is.
|
||||
let headers = self.sigil().is_none();
|
||||
// dropped — it would name what the whole list already is. The action
|
||||
// page is one kind too.
|
||||
let headers = self.sigil().is_none() && self.page.is_none();
|
||||
for group in self.groups() {
|
||||
if headers {
|
||||
lines.push(Line::from(Span::styled(
|
||||
@@ -752,6 +891,23 @@ mod tests {
|
||||
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]
|
||||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||||
let mut bar = CommandBar::new(
|
||||
@@ -1219,4 +1375,163 @@ mod tests {
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl TuiConfig {
|
||||
TuiTheme::Nord => Palette::NORD,
|
||||
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)
|
||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||
@@ -146,6 +146,25 @@ impl TuiConfig {
|
||||
keymap
|
||||
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
||||
.expect("leader canonical form is valid by construction");
|
||||
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
|
||||
// the same leader spec so rebinding leader rebinds this chord too.
|
||||
keymap
|
||||
.set(
|
||||
Mode::Insert,
|
||||
&format!("{} ctrl-k", leader.canonical()),
|
||||
"SendLiteralCtrlK",
|
||||
)
|
||||
.expect("leader canonical form is valid by construction");
|
||||
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
||||
// pressing it twice sends the leader byte itself. Built from the same
|
||||
// leader spec, so rebinding leader rebinds this chord too.
|
||||
keymap
|
||||
.set(
|
||||
Mode::Insert,
|
||||
&format!("{} {}", leader.canonical(), leader.canonical()),
|
||||
"SendLiteralLeader",
|
||||
)
|
||||
.expect("leader canonical form is valid by construction");
|
||||
|
||||
for (input, action) in §ion.keymap_normal {
|
||||
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);
|
||||
}
|
||||
|
||||
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
|
||||
/// leader, `ctrl-a ctrl-k` — not the command bar.
|
||||
#[test]
|
||||
fn default_leader_escapes_a_literal_ctrl_k() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
|
||||
Some(Action::SendLiteralCtrlK),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
|
||||
/// just the Insert escape — proving the chord is built from the
|
||||
/// configured leader rather than a hardcoded `ctrl-a`.
|
||||
#[test]
|
||||
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-b".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
|
||||
Some(Action::SendLiteralCtrlK),
|
||||
);
|
||||
// The old leader's chord is gone along with its Insert escape.
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
|
||||
}
|
||||
|
||||
/// The literal-leader escape hatch (#158) is chorded off the leader
|
||||
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
||||
#[test]
|
||||
fn default_leader_escapes_a_literal_leader() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebinding the leader must rebind the literal-leader chord with it,
|
||||
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
||||
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
||||
#[test]
|
||||
fn rebinding_leader_moves_the_literal_leader_chord() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-b".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-b"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
// The old leader's chord is gone along with the rest of its bindings.
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_normal_override_replaces_default() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
@@ -250,6 +250,7 @@ mod tests {
|
||||
dirty: false,
|
||||
},
|
||||
instance: instance.into(),
|
||||
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
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),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// 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),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
seed_command: None,
|
||||
custom_command: crate::text_input::TextInput::default(),
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
||||
.iter()
|
||||
.position(|p| !p.command.is_empty())
|
||||
.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
|
||||
@@ -1679,6 +1705,7 @@ mod tests {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
resume_command: None,
|
||||
}]);
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
@@ -1688,6 +1715,51 @@ mod tests {
|
||||
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]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -183,6 +183,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
resume_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,9 @@ pub struct ChordState {
|
||||
held: Vec<String>,
|
||||
last_press: Option<Instant>,
|
||||
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 {
|
||||
@@ -395,6 +398,7 @@ impl ChordState {
|
||||
held: Vec::new(),
|
||||
last_press: None,
|
||||
timeout,
|
||||
replay: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +408,13 @@ impl ChordState {
|
||||
/// binding; wait for the next key.
|
||||
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
|
||||
/// 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 {
|
||||
if let Some(t) = self.last_press {
|
||||
if t.elapsed() > self.timeout {
|
||||
@@ -428,11 +439,31 @@ impl ChordState {
|
||||
|
||||
if is_prefix {
|
||||
self.held.push(key_s);
|
||||
ChordResult::Pending
|
||||
} else {
|
||||
self.held.clear();
|
||||
ChordResult::Resolved(candidate)
|
||||
return ChordResult::Pending;
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn chord_state_lone_key_resolves_immediately() {
|
||||
let mut chord = ChordState::new(Duration::from_secs(10));
|
||||
|
||||
@@ -268,6 +268,8 @@ impl Sidebar {
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
// #162: known from its sidecar, nothing running behind it.
|
||||
(SessionStatus::Dormant, _) => "◌",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
@@ -322,6 +324,7 @@ impl Sidebar {
|
||||
// on line 1; the kill confirm names the specifics.
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let dormant = s.status == SessionStatus::Dormant;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
@@ -361,6 +364,9 @@ impl Sidebar {
|
||||
if dead {
|
||||
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.
|
||||
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.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
|
||||
+24
-11
@@ -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
|
||||
useful.
|
||||
|
||||
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
|
||||
none of them needs one.
|
||||
One knob drives both clients: the daemon serves `[tui].leader` as `leader` on
|
||||
`GET /api/info`, and the web binds that instead of its own constant (#159). A
|
||||
daemon too old to send the field, or an unreachable `/api/info`, leaves the web
|
||||
on `ctrl-a`. The section is named `[tui]` for history — it is the leader for
|
||||
every client, not the TUI's alone.
|
||||
|
||||
The leader itself needs the same kind of way back: holding it consumes every
|
||||
press, on both clients, even before anything is bound behind it. Pressing it
|
||||
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||
focused pane and clears the pending chord, the way tmux resolves a doubled
|
||||
prefix key. This chord is also built from the configured leader, so a
|
||||
rebound leader carries its own escape hatch with it.
|
||||
|
||||
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
||||
character is intercepted, so none of them needs one.
|
||||
|
||||
### Focus round-trip
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
ones regardless of recency: a session you cannot attach to is not where you
|
||||
just were.
|
||||
and among those, most recently created first. Sessions with no live pane —
|
||||
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
|
||||
session you cannot attach to is not where you just were.
|
||||
|
||||
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||
lists recent dirs, most recent first, then the project list.
|
||||
@@ -145,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
|
||||
ambiguous.
|
||||
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||
previous query intact**. Drilling in and backing out is free.
|
||||
- Each action raises the flow it already has: kill goes through the existing
|
||||
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
|
||||
existing prompt pre-filled with the session's own name
|
||||
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
|
||||
session's directory and command. The bar picks the action; it never
|
||||
reimplements one.
|
||||
- Each action raises the flow it already has: kill through the confirmation
|
||||
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
|
||||
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
|
||||
seeded from the session's directory and command. The bar picks the action; it
|
||||
never reimplements one.
|
||||
|
||||
No actions beyond those three, and no action across more than one session.
|
||||
|
||||
|
||||
+2
-1
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
|
||||
```toml
|
||||
[tui]
|
||||
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`
|
||||
|
||||
[tui.keymap.normal]
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||
|
||||
+27
-6
@@ -56,7 +56,7 @@ itself is not.
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
|
||||
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
|
||||
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 |
|
||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
||||
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
|
||||
|
||||
The web ladder is written as one ordered run of equal-specificity rules in
|
||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||
@@ -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
|
||||
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. Only `+` and `-` step outside the surface's muted role, and they
|
||||
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 |
|
||||
| `c` | collapse or expand the highlighted folder |
|
||||
| `r` | rename the highlighted session |
|
||||
| `x` | kill the highlighted session |
|
||||
| `x` | kill the highlighted session (confirms) |
|
||||
| `n` | open the spawn form |
|
||||
| `N` | spawn with every default resolved |
|
||||
| `?` | 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
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
in both help overlays.
|
||||
|
||||
## Killing a session
|
||||
|
||||
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||
and unattended agents live in these sessions, so neither client sends
|
||||
`DELETE /api/sessions/:id` off a single press. The TUI raises
|
||||
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
|
||||
`✕` button and the command bar's action page both use. Cancel holds the focus
|
||||
in the web's dialog, so nothing kills on a stray Enter.
|
||||
|
||||
The confirmation names what the card last saw in the way, when anything was —
|
||||
the daemon will refuse an unforced kill over such a worktree, and saying so
|
||||
before the attempt is cheaper than a refusal. What comes back from that refusal
|
||||
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
|
||||
what a forced kill leaves behind.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||
// kill or rename and raises the flow the card already has, so what the page
|
||||
// runs is what the ✕ button and the `r` key run.
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "amber-falcon" },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(s: Session): HTMLElement {
|
||||
const handle = renderSidebar(storeWith([s]));
|
||||
mounted = handle;
|
||||
document.body.appendChild(handle.el);
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function raise(name: string, s: Session): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("kill from the action page", () => {
|
||||
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const s = session();
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||
"Kill amber-falcon",
|
||||
);
|
||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||
window.fetch = ((url: string) => {
|
||||
if (String(url).includes("force=true")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename from the action page", () => {
|
||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||
folder?.click();
|
||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(input?.value).toBe("amber-falcon");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||
});
|
||||
});
|
||||
+53
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("kills straight away, without a first confirmation", async () => {
|
||||
it("confirms before an unguarded kill (#160)", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
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(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 () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await flush();
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||
@@ -375,6 +414,19 @@ describe("session cards (#107)", () => {
|
||||
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", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
|
||||
+119
-40
@@ -1,12 +1,13 @@
|
||||
import { escapeHtml } from "./html.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 { toggleHelp } from "./help.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
sessionTreePath,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
@@ -37,6 +39,15 @@ export function requestOpenSpawn(): void {
|
||||
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 —
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
@@ -50,6 +61,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||
* through the same confirm-and-blockers flow the card's ✕ does
|
||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||
|
||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
@@ -91,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
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 slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
@@ -105,6 +130,7 @@ function metaLine(s: Session, now: number): string {
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
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>`;
|
||||
}
|
||||
@@ -208,7 +234,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(cur, sessionName(cur));
|
||||
requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
@@ -226,7 +252,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -271,26 +297,33 @@ function treeSignature(node: TreeNode): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||
* a forced kill. Anything else surfaces as an alert.
|
||||
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||
*/
|
||||
async function requestKill(s: Session, name: string): Promise<void> {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(`Failed to kill: ${(e as Error).message}`);
|
||||
}
|
||||
function requestKill(s: Session, name: string): void {
|
||||
openKillConfirmDialog({
|
||||
name,
|
||||
blockers: sessionBlockers(s),
|
||||
onKill: async () => {
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
openForceKillDialog({
|
||||
name,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
reason: e.detail,
|
||||
onForce: () => killSession(s.id, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,16 +381,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
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");
|
||||
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 = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
@@ -428,12 +457,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// renders with absolute paths until it lands.
|
||||
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");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
readonly
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
@@ -542,11 +575,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
handle.addEventListener("pointerup", 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;
|
||||
let filter = "";
|
||||
search.addEventListener("input", () => {
|
||||
filter = search.value;
|
||||
refresh();
|
||||
const openBar = () => {
|
||||
search.value = "";
|
||||
search.blur();
|
||||
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 };
|
||||
@@ -685,7 +730,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
"list.kill": () => {
|
||||
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)),
|
||||
}),
|
||||
@@ -703,8 +748,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
const tree = buildTree(sessions, homeDir);
|
||||
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
@@ -715,9 +760,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
@@ -745,6 +788,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// The command bar's action page (#151) picks an action and hands it here:
|
||||
// both flows are the ones the card already has, reached by session rather
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const row = rowForSession(s);
|
||||
if (row) beginRename(row, s);
|
||||
};
|
||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
|
||||
/** The card a session is rendered as, expanding whatever folder hides it
|
||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||
* would otherwise swallow the request. */
|
||||
function rowForSession(s: Session): HTMLElement | null {
|
||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||
const found = find();
|
||||
if (found) return found;
|
||||
// Folder paths are the abbreviated directory's segments joined back up
|
||||
// (see buildTree), so the session's own path is normalised the same way
|
||||
// before it is compared against them.
|
||||
const cwd = sessionTreePath(s);
|
||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||
for (const folder of [...collapsed]) {
|
||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||
}
|
||||
rerenderHandler();
|
||||
return find();
|
||||
}
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
@@ -794,6 +871,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
+194
-1
@@ -1,7 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionActionCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
@@ -93,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", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
@@ -542,3 +562,176 @@ describe("the focus round-trip over a pane (#152)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page (#151)", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [],
|
||||
onOpenSession: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function headerText(): string | undefined {
|
||||
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
expect(activeName()).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
type("spwn");
|
||||
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||
type("zzz");
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||
onOpenSession: () => {},
|
||||
});
|
||||
type(">");
|
||||
const before = rowNames();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(before);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||
type("~");
|
||||
await Promise.resolve();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type("");
|
||||
press("Backspace");
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
press("Backspace");
|
||||
expect(headerText()).toContain("api");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
const seen: string[] = [];
|
||||
for (const [hook, key] of [
|
||||
["onKillSession", "Kill session"],
|
||||
["onRenameSession", "Rename session"],
|
||||
["onSpawnSimilar", "Spawn similar"],
|
||||
] as const) {
|
||||
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type(key);
|
||||
press("Enter");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"onKillSession:worker",
|
||||
"onRenameSession:worker",
|
||||
"onSpawnSimilar:worker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
const seen: string[] = [];
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
];
|
||||
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||
try {
|
||||
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||
open();
|
||||
press("Tab");
|
||||
type(action);
|
||||
press("Enter");
|
||||
}
|
||||
} finally {
|
||||
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||
}
|
||||
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||
});
|
||||
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+157
-13
@@ -2,11 +2,16 @@
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Three kinds live here: sessions and commands, ranked together by default,
|
||||
// and directories, which need the `~` sigil. The action page and the
|
||||
// terminal-focus binding land in #151-#152.
|
||||
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||
// its action page (#151), which is the same overlay over three actions.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
@@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||
@@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery {
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
@@ -97,8 +142,10 @@ function createdAt(s: Session): number {
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
||||
// — sorts after the sessions you could actually attach to.
|
||||
const deadA = a.status === "Running" ? 0 : 1;
|
||||
const deadB = b.status === "Running" ? 0 : 1;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
@@ -305,7 +352,9 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
if (candidate.session.status !== "Running") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
row.dataset.commandId = candidate.command.id;
|
||||
} else {
|
||||
@@ -322,7 +371,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
|
||||
// The chord is teaching material, not a match target: it is rendered from
|
||||
// the live registry entry and never fed to the matcher.
|
||||
if (candidate.kind === "command") {
|
||||
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||
const keys = document.createElement("span");
|
||||
keys.className = "cmdbar-row-keys";
|
||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||
@@ -374,6 +423,13 @@ export interface CommandBarOptions {
|
||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||
* spawn. */
|
||||
onSpawnDirectory?: (path: string) => void;
|
||||
/** The action page's three actions (#151). Each defaults to raising the
|
||||
* event the flow that already implements it listens on — the sidebar's
|
||||
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||
* seeded from the session. The bar picks the action and nothing more. */
|
||||
onKillSession?: (session: Session) => void;
|
||||
onRenameSession?: (session: Session) => void;
|
||||
onSpawnSimilar?: (session: Session) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
@@ -406,6 +462,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
opts.onSpawnDirectory ??
|
||||
((path: string) =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||
const sessionEvent =
|
||||
(name: string) =>
|
||||
(session: Session): void => {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||
};
|
||||
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||
const listEntries =
|
||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||
@@ -450,6 +514,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||
// the result-list query it was drilled into from — backing out restores it,
|
||||
// so the round trip costs the operator nothing.
|
||||
let page: Session | null = null;
|
||||
let stashed = "";
|
||||
let header: HTMLElement | null = null;
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
let directories: DirectoryBarCandidate[] = [];
|
||||
@@ -516,6 +587,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
@@ -547,9 +619,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
/** The line naming the session being acted on, so the target is never
|
||||
* ambiguous. Present only while the action page is up. */
|
||||
function paintHeader(): void {
|
||||
header?.remove();
|
||||
header = null;
|
||||
if (!page) return;
|
||||
header = document.createElement("div");
|
||||
header.className = "cmdbar-page-header";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||
panel.insertBefore(header, results);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const parsed = parseBarQuery(input.value);
|
||||
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||
input.placeholder = page
|
||||
? `Act on ${sessionName(page)}…`
|
||||
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
paintHeader();
|
||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
@@ -567,8 +657,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
// While a sigil is in force there is one kind, so the header is dropped.
|
||||
if (parsed.sigil === null) {
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — and the action page is one kind too.
|
||||
if (parsed.sigil === null && !page) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
@@ -593,10 +684,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
function drillIn(): void {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
page = candidate.session;
|
||||
stashed = input.value;
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
function leavePage(): void {
|
||||
page = null;
|
||||
input.value = stashed;
|
||||
stashed = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function runAction(action: SessionAction, session: Session): void {
|
||||
close();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
killSession(session);
|
||||
return;
|
||||
case "rename":
|
||||
renameSession(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
spawnSimilar(session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
@@ -615,7 +746,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else close();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && input.value === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
|
||||
@@ -4,14 +4,17 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMMAND_SPECS,
|
||||
CommandSet,
|
||||
commandSpecs,
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
controlByte,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
isTerminalTextarea,
|
||||
isTypingTarget,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
type Command,
|
||||
} from "./commands.js";
|
||||
|
||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
@@ -163,6 +166,37 @@ describe("the leader", () => {
|
||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||
});
|
||||
|
||||
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
||||
// behind it is built from it, so a rebound leader carries its escape hatches
|
||||
// with it instead of leaving them on a stale ctrl-a.
|
||||
it("builds the chords behind it from the configured leader", () => {
|
||||
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||
// Commands that do not hang off the leader are untouched.
|
||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
|
||||
it("dispatches a configured leader's chord and not the default's", () => {
|
||||
const literalCtrlK = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
||||
leader: "ctrl-b",
|
||||
});
|
||||
set.handle(key("b", { ctrlKey: true }));
|
||||
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
||||
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The old leader is an ordinary key again: it holds nothing, and the chord
|
||||
// that used to follow it runs nothing.
|
||||
set.handle(key("a", { ctrlKey: true }));
|
||||
set.handle(key("k", { ctrlKey: true }));
|
||||
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends the configured leader's own byte for the doubled chord", () => {
|
||||
expect(controlByte("ctrl-b")).toBe("\x02");
|
||||
});
|
||||
|
||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||
leader: DEFAULT_LEADER,
|
||||
@@ -307,3 +341,62 @@ describe("ctrl-k over a focused pane (#152)", () => {
|
||||
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
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
||||
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
|
||||
* is select-all in a browser, so a set that owns the leader
|
||||
* `preventDefault`s it. */
|
||||
export const DEFAULT_LEADER = "ctrl-a";
|
||||
|
||||
/** Every command the web client has. The single list this issue exists to
|
||||
* create: labels, chords and grouping live here and nowhere else. */
|
||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
/** Every command the web client has, for one leader. The single list: labels,
|
||||
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||
* built from it (#159), so the configured leader — served on `/api/info` as
|
||||
* `leader` — is what the overlay renders and the sets dispatch. */
|
||||
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
||||
return SPECS.map(({ leaderKeys, ...spec }) =>
|
||||
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
||||
);
|
||||
}
|
||||
|
||||
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
||||
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
||||
|
||||
const SPECS: readonly SpecSource[] = [
|
||||
{
|
||||
id: "spawn.open",
|
||||
label: "Open the spawn form",
|
||||
@@ -65,6 +78,15 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
label: "Send a literal Ctrl-K to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "pane.literalLeader",
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
@@ -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
|
||||
* test failure rather than a key that silently does nothing. */
|
||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
||||
* test failure rather than a key that silently does nothing. `leader` picks
|
||||
* the chords behind the leader; omit it for a set that has none. */
|
||||
export function defineCommands(
|
||||
handlers: Record<string, () => void>,
|
||||
leader: string = DEFAULT_LEADER,
|
||||
): Command[] {
|
||||
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
||||
return Object.entries(handlers).map(([id, run]) => {
|
||||
const spec = SPEC_BY_ID.get(id);
|
||||
const spec = byId.get(id);
|
||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||
return { ...spec, run };
|
||||
});
|
||||
@@ -227,6 +256,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
|
||||
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
|
||||
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
|
||||
* itself so a different leader sends its own byte, not a hardcoded one. */
|
||||
export function controlByte(chord: string): string {
|
||||
const m = /^ctrl-([a-z])$/.exec(chord);
|
||||
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
|
||||
return String.fromCharCode(m[1].charCodeAt(0) - 96);
|
||||
}
|
||||
|
||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||
|
||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||
|
||||
+28
-1
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
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";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +44,33 @@ describe("help overlay", () => {
|
||||
.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 ?", () => {
|
||||
press("?", { shiftKey: true });
|
||||
expect(isHelpOpen()).toBe(true);
|
||||
|
||||
+83
-22
@@ -1,14 +1,44 @@
|
||||
/**
|
||||
* Forced-kill prompt (#81).
|
||||
* The two kill prompts (#81, #160).
|
||||
*
|
||||
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
||||
* refusal gets its own dialog: it names what is in the way and offers the
|
||||
* override the daemon's `force=true` already implements. A kill the daemon
|
||||
* accepts never reaches here — there is nothing to warn about.
|
||||
* A kill is destructive and unattended agents live in these sessions, so both
|
||||
* clients ask before one goes through — the TUI's `KillConfirm`, and
|
||||
* `openKillConfirmDialog` here (#160). Where the session's worktree still
|
||||
* holds uncommitted or unpushed work, the confirmation says so up front, the
|
||||
* 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";
|
||||
|
||||
/** 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 {
|
||||
/** Session name, as the sidebar shows it. */
|
||||
name: string;
|
||||
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
|
||||
open = null;
|
||||
}
|
||||
|
||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
||||
* can never stack two dialogs. */
|
||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
/** Mount one of the prompts. Opening while one is up replaces it, so the
|
||||
* confirmation and a refusal that follows it can never stack. */
|
||||
function mount(p: KillDialogSpec): HTMLElement {
|
||||
closeForceKillDialog();
|
||||
|
||||
const el = document.createElement("div");
|
||||
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
|
||||
const body = document.createElement("p");
|
||||
body.className = "kill-dialog-body";
|
||||
body.textContent = 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.`;
|
||||
body.textContent = p.body;
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "kill-dialog-error";
|
||||
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", closeForceKillDialog);
|
||||
const force = document.createElement("button");
|
||||
force.type = "button";
|
||||
force.className = "btn btn-danger kill-dialog-force";
|
||||
force.textContent = "Force kill";
|
||||
force.addEventListener("click", () => {
|
||||
force.disabled = true;
|
||||
const confirm = document.createElement("button");
|
||||
confirm.type = "button";
|
||||
confirm.className = `btn btn-danger ${p.confirmClass}`;
|
||||
confirm.textContent = p.confirmLabel;
|
||||
confirm.addEventListener("click", () => {
|
||||
confirm.disabled = true;
|
||||
void p
|
||||
.onForce()
|
||||
.then(closeForceKillDialog)
|
||||
.onConfirm()
|
||||
.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) => {
|
||||
force.disabled = false;
|
||||
if (open?.el !== el) return;
|
||||
confirm.disabled = false;
|
||||
error.hidden = false;
|
||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||
});
|
||||
});
|
||||
actions.append(cancel, force);
|
||||
actions.append(cancel, confirm);
|
||||
|
||||
panel.append(title, body, error, actions);
|
||||
|
||||
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
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();
|
||||
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
@@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
@@ -36,6 +36,7 @@ import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
OPEN_COMMAND_BAR_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
@@ -86,9 +87,15 @@ const store = new CatalogStore();
|
||||
// Absolute paths are a fine fallback until it lands.
|
||||
let homeDir: string | undefined;
|
||||
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(() => {
|
||||
/* 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
|
||||
@@ -438,41 +445,68 @@ const spawnKeys = new CommandSet(
|
||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||
// the focus back out of it.
|
||||
//
|
||||
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
||||
// key as a partial chord and takes select-all off it, so the escape hatch
|
||||
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
||||
const focusKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"focus.toggle": () => {
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} else {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
|
||||
// away from every pane, and leader ctrl-k gives it back as the literal
|
||||
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
||||
// bar's, so the chord never opens the bar.
|
||||
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||
}),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
||||
// (#149).
|
||||
let currentLeader = DEFAULT_LEADER;
|
||||
|
||||
function buildFocusKeys(leader: string): CommandSet {
|
||||
return new CommandSet(
|
||||
defineCommands(
|
||||
{
|
||||
"focus.toggle": () => {
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} else {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
}
|
||||
},
|
||||
// The escape hatch: binding ctrl-k takes readline's
|
||||
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
||||
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
||||
// this set, ahead of the bar's, so the chord never opens the bar.
|
||||
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||
// The leader itself, pressed twice, tmux-style: sends one literal
|
||||
// leader byte and clears the pending chord instead of resolving as a
|
||||
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
||||
// hardcoded byte, so a rebound leader sends its own.
|
||||
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
||||
},
|
||||
leader,
|
||||
),
|
||||
{ leader },
|
||||
);
|
||||
}
|
||||
|
||||
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
||||
|
||||
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
||||
* same command ids replaces them, so the help overlay and the command bar —
|
||||
* both of which render the published registry when they open — show the
|
||||
* configured chord rather than the default. */
|
||||
function useLeader(leader: string): void {
|
||||
if (leader === currentLeader) return;
|
||||
currentLeader = leader;
|
||||
focusKeys = buildFocusKeys(leader);
|
||||
}
|
||||
|
||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||
// like any other, so the `?` overlay lists it without a second list.
|
||||
const barKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"bar.open": () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const openBar = () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
};
|
||||
|
||||
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
||||
|
||||
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
||||
// phone, so the field the operator already reaches for opens the same bar.
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
|
||||
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 killed: string[] = [];
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "x");
|
||||
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||
await Promise.resolve();
|
||||
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+30
-4
@@ -1202,8 +1202,10 @@ textarea {
|
||||
}
|
||||
|
||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||
session that is already open still reads as provisional. */
|
||||
.tree-leaf:where(.is-previewing) {
|
||||
session that is already open still reads as provisional — except the session
|
||||
already mounted in the pane (tier 4). Previewing that one changes nothing on
|
||||
screen, so its accent stays put instead of blinking out under the pointer. */
|
||||
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
|
||||
background: var(--preview-tint);
|
||||
}
|
||||
|
||||
@@ -1472,6 +1474,15 @@ input.leaf-rename:focus {
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
/* The action page's header (#151): names the session being acted on, so the
|
||||
target of kill/rename/spawn-similar is never ambiguous. */
|
||||
.cmdbar-page-header {
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cmdbar-group-title {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: var(--fs-11);
|
||||
@@ -1549,7 +1560,10 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
grid-template-rows: 1fr auto;
|
||||
padding: 0;
|
||||
@@ -1558,9 +1572,21 @@ input.leaf-rename:focus {
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border-bottom: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Thumb-sized rows and a field that clears the on-screen keyboard's
|
||||
zoom-on-focus threshold (16px). */
|
||||
.cmdbar-input {
|
||||
font-size: var(--fs-17);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cmdbar-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-26
@@ -26,7 +26,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/work"]);
|
||||
@@ -35,7 +34,7 @@ describe("buildTree", () => {
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -43,7 +42,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||
@@ -53,7 +51,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/git"]);
|
||||
@@ -65,30 +62,11 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("loose", ""), session("nested", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(root.children[0].kind).toBe("folder");
|
||||
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", () => {
|
||||
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
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", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-6
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
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 lc = filter.trim().toLowerCase();
|
||||
|
||||
for (const s of sessions) {
|
||||
// Grouped by repository, so a worktree session lands under its repo
|
||||
// rather than in a folder of its own (#99).
|
||||
const cwd = sessionTreePath(s);
|
||||
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) : [];
|
||||
let cursor = root;
|
||||
for (const part of parts) {
|
||||
|
||||
+8
-1
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
|
||||
export type Uuid = 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).
|
||||
* 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
|
||||
* daemons, which the restart flow treats as "cannot tell". */
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user