Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9728b10ffd | ||
|
|
1b2b392710 | ||
|
|
72644dd564 | ||
|
|
8325bc6d9a | ||
|
|
8f499dbee3 | ||
|
|
511fed408c | ||
|
|
ba02b11f13 |
@@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
|
SessionStatus::Dormant => "dormant",
|
||||||
};
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
resume_command: None,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ pub const FILE_NAME: &str = "config.toml";
|
|||||||
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
||||||
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||||
|
|
||||||
|
/// Leader key both clients use when `[tui].leader` is unset (#159). Canonical
|
||||||
|
/// key form, the spelling `InputKey::parse` and the web's `canonicalizeKey`
|
||||||
|
/// both produce.
|
||||||
|
pub const DEFAULT_LEADER: &str = "ctrl-a";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
@@ -152,6 +157,15 @@ pub struct TuiSection {
|
|||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TuiSection {
|
||||||
|
/// The configured leader, or [`DEFAULT_LEADER`]. Named `[tui].leader` for
|
||||||
|
/// history, but read by every client: the daemon serves it on
|
||||||
|
/// `GET /api/info` so the web follows the same knob (#159).
|
||||||
|
pub fn leader(&self) -> &str {
|
||||||
|
self.leader.as_deref().unwrap_or(DEFAULT_LEADER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TuiTheme {
|
pub enum TuiTheme {
|
||||||
@@ -299,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
resume_command: Some("claude --resume {agent_session}".into()),
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -320,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
resume_command: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +511,18 @@ auto_install = false
|
|||||||
assert!(custom.command.is_empty());
|
assert!(custom.command.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_claude_preset_resumes_by_agent_session() {
|
||||||
|
let claude = builtin_presets()
|
||||||
|
.into_iter()
|
||||||
|
.find(|preset| preset.id == "claude")
|
||||||
|
.expect("Claude preset");
|
||||||
|
assert_eq!(
|
||||||
|
claude.resume_command.as_deref(),
|
||||||
|
Some("claude --resume {agent_session}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_presets_override_replaces_builtins() {
|
fn spawn_presets_override_replaces_builtins() {
|
||||||
// Present list replaces the built-ins entirely; custom still appended.
|
// Present list replaces the built-ins entirely; custom still appended.
|
||||||
@@ -501,6 +531,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
resume_command: None,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -537,6 +568,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
resume_command: None,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -547,6 +579,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
resume_command: None,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -564,11 +597,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -583,6 +618,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
resume_command: None,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -618,6 +654,27 @@ presets = [
|
|||||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_preset_parses_resume_command_from_issue_example() {
|
||||||
|
let cfg = toml::from_str::<MaestroConfigFile>(
|
||||||
|
r#"
|
||||||
|
[spawn]
|
||||||
|
presets = [
|
||||||
|
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
|
||||||
|
]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("issue #165 example parses");
|
||||||
|
|
||||||
|
let preset = &cfg.spawn.presets.expect("configured presets")[0];
|
||||||
|
assert_eq!(preset.id, "claude");
|
||||||
|
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
|
||||||
|
assert_eq!(
|
||||||
|
preset.resume_command.as_deref(),
|
||||||
|
Some("claude --dangerously-skip-permissions --resume {agent_session}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_project_scan_fields_default_to_none() {
|
fn spawn_project_scan_fields_default_to_none() {
|
||||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
|
|||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|
||||||
|
// This client's claim on the shared window size. Registered up front and
|
||||||
|
// released on every exit below, so a socket that dies mid-session stops
|
||||||
|
// constraining the size for the clients still attached.
|
||||||
|
let viewport = manager.attach_viewport();
|
||||||
|
|
||||||
// NAP-229: send buffered scrollback up-front so the client sees the last
|
// NAP-229: send buffered scrollback up-front so the client sees the last
|
||||||
// screenful immediately instead of a blank terminal. The replay lock the
|
// screenful immediately instead of a blank terminal. The replay lock the
|
||||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||||
// here and on the live receiver, and none is dropped at the seam.
|
// here and on the live receiver, and none is dropped at the seam.
|
||||||
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
|
|||||||
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
|
||||||
match ctrl {
|
match ctrl {
|
||||||
ClientControl::Resize { cols, rows } => {
|
ClientControl::Resize { cols, rows } => {
|
||||||
let _ = manager.resize(id, cols, rows).await;
|
let _ = manager.set_viewport(id, viewport, cols, rows).await;
|
||||||
}
|
}
|
||||||
ClientControl::Ping => {
|
ClientControl::Ping => {
|
||||||
let pong = serde_json::to_string(&ClientControl::Pong)
|
let pong = serde_json::to_string(&ClientControl::Pong)
|
||||||
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manager.release_viewport(id, viewport).await;
|
||||||
|
|
||||||
if session_ended {
|
if session_ended {
|
||||||
let _ = sink.send(Message::Close(None)).await;
|
let _ = sink.send(Message::Close(None)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
|
config.leader.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ pub struct DaemonConfig {
|
|||||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||||
/// `true`.
|
/// `true`.
|
||||||
pub hooks_auto_install: bool,
|
pub hooks_auto_install: bool,
|
||||||
|
/// Canonical leader key advertised to clients in `GET /api/info` (#159):
|
||||||
|
/// `[tui].leader`, default [`maestro_config::DEFAULT_LEADER`]. The daemon
|
||||||
|
/// never binds it itself; it serves it so the web client and the TUI read
|
||||||
|
/// one knob.
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
@@ -197,6 +202,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
project_roots,
|
project_roots,
|
||||||
project_scan_depth,
|
project_scan_depth,
|
||||||
hooks_auto_install: file.hooks.auto_install(),
|
hooks_auto_install: file.hooks.auto_install(),
|
||||||
|
leader: file.tui.leader().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +239,29 @@ pub fn system_hostname() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// #159: the leader the operator set for the TUI is what the daemon
|
||||||
|
/// advertises, so rebinding it once moves both clients' chord.
|
||||||
|
#[test]
|
||||||
|
fn leader_defaults_to_ctrl_a_and_follows_the_tui_section() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
std::fs::write(&path, "").unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-a");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[tui]
|
||||||
|
leader = "ctrl-b"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
|
assert_eq!(cfg.leader, "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod spawn_history;
|
|||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
pub mod static_bundle;
|
pub mod static_bundle;
|
||||||
pub mod tmux;
|
pub mod tmux;
|
||||||
|
pub mod viewport;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
pub mod worktree_probe;
|
pub mod worktree_probe;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ pub struct LocalApiState {
|
|||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
/// Configured leader key (`[tui].leader`) advertised in `GET /api/info`
|
||||||
|
/// (#159), so the web client binds the chord the operator configured
|
||||||
|
/// rather than its own default.
|
||||||
|
pub leader: Arc<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
@@ -219,6 +223,7 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
|||||||
presets: (*s.presets).clone(),
|
presets: (*s.presets).clone(),
|
||||||
build: crate::release::build_info(),
|
build: crate::release::build_info(),
|
||||||
instance: crate::release::instance_id().to_string(),
|
instance: crate::release::instance_id().to_string(),
|
||||||
|
leader: (*s.leader).clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +379,7 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
|
leader: impl Into<String>,
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
@@ -381,6 +387,7 @@ pub fn state_for(
|
|||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
projects,
|
||||||
|
leader: Arc::new(leader.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
|
|||||||
use crate::sidecar::{SidecarError, SidecarStore};
|
use crate::sidecar::{SidecarError, SidecarStore};
|
||||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||||
use crate::tmux::TmuxError;
|
use crate::tmux::TmuxError;
|
||||||
|
use crate::viewport::{ViewportId, Viewports};
|
||||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||||
|
|
||||||
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
||||||
@@ -60,7 +62,10 @@ pub struct SessionRecord {
|
|||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
pub bridge: PtyBridge,
|
/// The PTY bridge feeding this session's attach stream. `None` for a
|
||||||
|
/// `Dormant` session (#162): there is no pane behind it, so there is
|
||||||
|
/// nothing to pipe and nothing to attach to.
|
||||||
|
pub bridge: Option<PtyBridge>,
|
||||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
/// outranks the activity pass's own inference; when it lapses, inference
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||||
@@ -166,6 +171,14 @@ struct Inner {
|
|||||||
report_ttl: Duration,
|
report_ttl: Duration,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<CatalogEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
|
/// Sizes reported by the clients attached to each session, and what tmux
|
||||||
|
/// was last told. See [`crate::viewport`] for why the window is not simply
|
||||||
|
/// set to whatever arrived most recently.
|
||||||
|
viewports: Mutex<Viewports>,
|
||||||
|
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
|
||||||
|
/// client that reconnects never collides with the registration its
|
||||||
|
/// previous socket has not finished releasing.
|
||||||
|
next_viewport: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -206,6 +219,8 @@ impl SessionManager {
|
|||||||
report_ttl: crate::activity::REPORT_TTL,
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
events,
|
events,
|
||||||
|
viewports: Mutex::new(Viewports::new()),
|
||||||
|
next_viewport: AtomicU64::new(0),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +294,10 @@ impl SessionManager {
|
|||||||
/// unknown to this daemon.
|
/// unknown to this daemon.
|
||||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
sessions
|
||||||
|
.get(&id)
|
||||||
|
.and_then(|r| r.bridge.as_ref())
|
||||||
|
.map(|b| b.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||||
@@ -298,19 +316,26 @@ impl SessionManager {
|
|||||||
let (result, updated) = {
|
let (result, updated) = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let rec = sessions.get_mut(&id)?;
|
let rec = sessions.get_mut(&id)?;
|
||||||
rec.session.last_focused_at = Some(Utc::now());
|
let SessionRecord {
|
||||||
let updated = rec.session.clone();
|
session, bridge, ..
|
||||||
|
} = &mut *rec;
|
||||||
|
// #162: a dormant session has no bridge and no pane — nothing was
|
||||||
|
// ever piped for it, so there is no stream to hand back and the
|
||||||
|
// attach reads as unknown. Checked before anything is stamped.
|
||||||
|
let bridge = bridge.as_ref()?;
|
||||||
|
session.last_focused_at = Some(Utc::now());
|
||||||
|
let updated = session.clone();
|
||||||
// For a live session, tmux's own screen beats the byte tail: the
|
// For a live session, tmux's own screen beats the byte tail: the
|
||||||
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
||||||
// replayed from it renders as garbage until something forces a
|
// replayed from it renders as garbage until something forces a
|
||||||
// repaint. A dead session has no pane left to capture, so it
|
// repaint. A dead session has no pane left to capture, so it
|
||||||
// keeps the byte tail.
|
// keeps the byte tail.
|
||||||
let alive = rec.session.status == SessionStatus::Running;
|
let alive = session.status == SessionStatus::Running;
|
||||||
// The capture shells out to tmux while the replay lock is held,
|
// The capture shells out to tmux while the replay lock is held,
|
||||||
// which parks the reader for the length of one tmux round-trip.
|
// which parks the reader for the length of one tmux round-trip.
|
||||||
// That only happens on attach, and the alternative is a seam in
|
// That only happens on attach, and the alternative is a seam in
|
||||||
// the stream.
|
// the stream.
|
||||||
let result = rec.bridge.subscribe_with_snapshot(|| {
|
let result = bridge.subscribe_with_snapshot(|| {
|
||||||
if !alive {
|
if !alive {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -487,7 +512,7 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
bridge,
|
bridge: Some(bridge),
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -695,7 +720,7 @@ impl SessionManager {
|
|||||||
// directory blocks that branch forever (#54 review).
|
// directory blocks that branch forever (#54 review).
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
bridge,
|
bridge: Some(bridge),
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
@@ -710,16 +735,89 @@ impl SessionManager {
|
|||||||
.send(CatalogEvent::SessionAdded { session });
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
}
|
}
|
||||||
|
self.register_dormant().await;
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward keystrokes to a session's pane. A dead session is read-only —
|
/// Second recovery pass (#162): register every sidecar tmux had nothing to
|
||||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
/// adopt as a [`SessionStatus::Dormant`] session.
|
||||||
/// client would have to interpret.
|
///
|
||||||
|
/// A host reboot kills the tmux server, so the first pass runs over an
|
||||||
|
/// empty list and every session would otherwise disappear from the API
|
||||||
|
/// while its sidecar sat untouched on disk. A dormant record keeps the id,
|
||||||
|
/// name, cwd, command, worktree and `created_at` — everything the sidecar
|
||||||
|
/// holds — so the operator still has a record of what was running. It has
|
||||||
|
/// no tmux session and no [`PtyBridge`] behind it: it cannot be attached
|
||||||
|
/// to, cannot be typed into, and is skipped by both watcher passes. The
|
||||||
|
/// only thing left to do with one is dismiss it, which removes the sidecar
|
||||||
|
/// so the next recovery does not bring it back.
|
||||||
|
///
|
||||||
|
/// Activity is not carried over: whatever the session was doing, it is not
|
||||||
|
/// doing it now. The rest of the sidecar is left as written.
|
||||||
|
async fn register_dormant(&self) {
|
||||||
|
let stored = match self.inner.sidecar.load_all() {
|
||||||
|
Ok(stored) => stored,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut registered = Vec::new();
|
||||||
|
{
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
for mut session in stored {
|
||||||
|
if sessions.contains_key(&session.id) {
|
||||||
|
// Adopted live by the first pass; its sidecar is on disk too.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = session.id;
|
||||||
|
session.status = SessionStatus::Dormant;
|
||||||
|
session.foreground = None;
|
||||||
|
session.activity = ActivityState::Idle;
|
||||||
|
session.activity_detail = None;
|
||||||
|
sessions.insert(
|
||||||
|
id,
|
||||||
|
SessionRecord {
|
||||||
|
session: session.clone(),
|
||||||
|
fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")),
|
||||||
|
// Still known, so a dismissal cleans the worktree up —
|
||||||
|
// the same reason recovery restores it for a live
|
||||||
|
// session (#54 review).
|
||||||
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
|
bridge: None,
|
||||||
|
reported_activity: None,
|
||||||
|
settled: false,
|
||||||
|
#[cfg(feature = "haiku-autoname")]
|
||||||
|
naming: false,
|
||||||
|
focused_since: None,
|
||||||
|
awaiting_suppressed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registered.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !registered.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
dormant = registered.len(),
|
||||||
|
"registered sessions with no live tmux as dormant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for session in registered {
|
||||||
|
let _ = self
|
||||||
|
.inner
|
||||||
|
.events
|
||||||
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward keystrokes to a session's pane. A dead or dormant session is
|
||||||
|
/// read-only — its pane is gone, so this is a no-op rather than a tmux
|
||||||
|
/// failure the client would have to interpret.
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -732,13 +830,15 @@ impl SessionManager {
|
|||||||
/// Type `text` into a session's pane, with an Enter after it when
|
/// Type `text` into a session's pane, with an Enter after it when
|
||||||
/// `submit` is set.
|
/// `submit` is set.
|
||||||
///
|
///
|
||||||
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
|
||||||
/// asked for text to land somewhere, and a silent success would let a
|
/// caller asked for text to land somewhere, and a silent success would let
|
||||||
/// script believe an agent had been told something it never heard.
|
/// a script believe an agent had been told something it never heard.
|
||||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => {
|
||||||
|
return Err(SessionError::NotRunning(id))
|
||||||
|
}
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -756,12 +856,12 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead or dormant session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.status(id).await {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||||
Some(SessionStatus::Running) => {}
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
@@ -770,6 +870,52 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a client that is about to start reporting its display size,
|
||||||
|
/// and hand back the handle it reports under.
|
||||||
|
///
|
||||||
|
/// Cheap and infallible: the session it will report against is not
|
||||||
|
/// consulted until the first [`Self::set_viewport`].
|
||||||
|
pub fn attach_viewport(&self) -> ViewportId {
|
||||||
|
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client reported the size it can display.
|
||||||
|
///
|
||||||
|
/// The shared tmux window is resized only when the size arbitrated across
|
||||||
|
/// every attached client actually changes — see [`crate::viewport`]. Two
|
||||||
|
/// clients of different sizes used to resize the window past each other on
|
||||||
|
/// every report, which leaves a full-screen agent repainting into rows it
|
||||||
|
/// never clears and looking, to the operator, like a pane that has stopped
|
||||||
|
/// accepting input.
|
||||||
|
pub async fn set_viewport(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
viewport: ViewportId,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), SessionError> {
|
||||||
|
let target = self
|
||||||
|
.inner
|
||||||
|
.viewports
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set(id, viewport, cols, rows);
|
||||||
|
match target {
|
||||||
|
Some((cols, rows)) => self.resize(id, cols, rows).await,
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a detached client's claim on the window size, resizing to what the
|
||||||
|
/// clients still attached can display. Errors are the caller's to ignore:
|
||||||
|
/// a socket closing because its session died has nothing left to resize.
|
||||||
|
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
|
||||||
|
let target = self.inner.viewports.lock().await.release(id, viewport);
|
||||||
|
if let Some((cols, rows)) = target {
|
||||||
|
let _ = self.resize(id, cols, rows).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
||||||
/// requested name is blank so the session falls back to its command.
|
/// requested name is blank so the session falls back to its command.
|
||||||
///
|
///
|
||||||
@@ -1116,7 +1262,11 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
rec.session.activity_detail = None;
|
rec.session.activity_detail = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let idle = rec.bridge.idle_for();
|
// Only `Running` sessions reach this pass, and every one of them
|
||||||
|
// has a bridge; a dormant session has neither pane nor bridge.
|
||||||
|
let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// #105: while a report is in force it supplies the state and
|
// #105: while a report is in force it supplies the state and
|
||||||
// nothing here may overwrite it; only an unreported session is
|
// nothing here may overwrite it; only an unreported session is
|
||||||
// decided by inference.
|
// decided by inference.
|
||||||
@@ -1234,8 +1384,10 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// alone and stays attached to the dead record for a later forced dismissal
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// (#81), which leaves it on disk too.
|
/// (#81), which leaves it on disk too.
|
||||||
///
|
///
|
||||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
/// A dead session does not come back as dead across a daemon restart: its
|
||||||
/// adopts only what tmux still holds.
|
/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
|
||||||
|
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
|
||||||
|
/// has a pane behind it.
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
let flipped = {
|
let flipped = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -1244,7 +1396,9 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
record.session.status = SessionStatus::Dead;
|
record.session.status = SessionStatus::Dead;
|
||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
if let Some(bridge) = record.bridge.as_mut() {
|
||||||
|
bridge.seal();
|
||||||
|
}
|
||||||
let session = record.session.clone();
|
let session = record.session.clone();
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
|
|||||||
@@ -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",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ async fn spawn(hostname: &str) -> Harness {
|
|||||||
async fn spawn_with(
|
async fn spawn_with(
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
) -> Harness {
|
||||||
|
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_with_leader(
|
||||||
|
hostname: &str,
|
||||||
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||||
|
leader: &str,
|
||||||
) -> Harness {
|
) -> Harness {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
@@ -48,6 +56,7 @@ async fn spawn_with(
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
projects,
|
||||||
|
leader,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -91,6 +100,38 @@ async fn info_returns_daemon_identity() {
|
|||||||
assert!(resp["version"].is_string());
|
assert!(resp["version"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #159: the web client reads its leader off `/api/info` instead of a
|
||||||
|
/// hardcoded constant, so the payload has to carry the configured one — not
|
||||||
|
/// the default dressed up as one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_advertises_the_configured_leader() {
|
||||||
|
let h = spawn_with_leader(
|
||||||
|
"naps-laptop",
|
||||||
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
"ctrl-b",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_reports_the_default_leader_when_unconfigured() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp["leader"], "ctrl-a");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn info_reports_build_and_instance() {
|
async fn info_reports_build_and_instance() {
|
||||||
// The status indicator needs a release to name and a process to compare
|
// The status indicator needs a release to name and a process to compare
|
||||||
|
|||||||
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
|
|||||||
Some(&"http://127.0.0.1:7070".to_string())
|
Some(&"http://127.0.0.1:7070".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two clients attached to one session used to resize the shared window past
|
||||||
|
/// each other on every report they made. Every flip repaints a full-screen
|
||||||
|
/// agent at a new height and orphans the rows it drew at the old one, which
|
||||||
|
/// reads to the operator as a pane that has stopped taking input.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
|
||||||
|
// redraw loop do. Neither report changes the arbitrated size.
|
||||||
|
for _ in 0..5 {
|
||||||
|
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
resizes,
|
||||||
|
vec![(53, 46), (53, 20)],
|
||||||
|
"the window was resized more than the arbitrated size actually changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smaller client leaving hands the window back to the one still attached.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn releasing_a_client_resizes_to_what_is_left() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let web = fx.manager.attach_viewport();
|
||||||
|
let tui = fx.manager.attach_viewport();
|
||||||
|
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
|
||||||
|
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
|
||||||
|
|
||||||
|
fx.manager.release_viewport(s.id, tui).await;
|
||||||
|
|
||||||
|
let resizes: Vec<_> = fx
|
||||||
|
.fake
|
||||||
|
.calls()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,140 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
assert_eq!(snap[0].command, "sleep 30");
|
assert_eq!(snap[0].command, "sleep 30");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
|
||||||
|
/// adopt while every sidecar is still on disk. Those sessions come back as
|
||||||
|
/// `Dormant` — a record of what was running — rather than vanishing from the
|
||||||
|
/// API and leaving the operator with nothing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_registers_orphaned_sidecars_as_dormant() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
metadata.insert(
|
||||||
|
maestro_protocol::NAME_KEY.to_string(),
|
||||||
|
serde_json::Value::String("after-reboot".into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
// The reboot: the tmux server is gone, the sidecar is untouched.
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
let adopted = revived.recover().await.expect("recover");
|
||||||
|
assert_eq!(adopted, 0, "no live tmux session left to adopt");
|
||||||
|
|
||||||
|
let snap = revived.snapshot().await;
|
||||||
|
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
|
||||||
|
assert_eq!(snap[0].id, s.id);
|
||||||
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||||
|
assert_eq!(snap[0].command, "sleep 30");
|
||||||
|
assert_eq!(snap[0].cwd, s.cwd);
|
||||||
|
assert_eq!(snap[0].created_at, s.created_at);
|
||||||
|
assert_eq!(
|
||||||
|
snap[0]
|
||||||
|
.metadata
|
||||||
|
.get(maestro_protocol::NAME_KEY)
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("after-reboot"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #162: a session tmux still holds is adopted `Running` by the first pass and
|
||||||
|
/// must not be re-registered as dormant by the second — the sidecar is on disk
|
||||||
|
/// for both.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_does_not_mark_adopted_sessions_dormant() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let live = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create live");
|
||||||
|
let orphan = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 31".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create orphan");
|
||||||
|
// Only the second session loses its pane.
|
||||||
|
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||||
|
|
||||||
|
let mut snap = revived.snapshot().await;
|
||||||
|
snap.sort_by_key(|s| s.command.clone());
|
||||||
|
assert_eq!(snap.len(), 2);
|
||||||
|
assert_eq!(snap[0].id, live.id);
|
||||||
|
assert_eq!(snap[0].status, SessionStatus::Running);
|
||||||
|
assert_eq!(snap[1].id, orphan.id);
|
||||||
|
assert_eq!(snap[1].status, SessionStatus::Dormant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #162: dismissing a dormant session is the one thing that can be done to it
|
||||||
|
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
|
||||||
|
/// does not resurrect it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dormant_session_can_be_dismissed() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
|
||||||
|
let revived = revive(&fx);
|
||||||
|
revived.recover().await.expect("recover");
|
||||||
|
revived.kill(s.id, false).await.expect("dismiss");
|
||||||
|
|
||||||
|
assert!(!revived.has(s.id).await);
|
||||||
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second `SessionManager` over the same dirs and the same tmux server —
|
||||||
|
/// what a daemon restart looks like from inside a test.
|
||||||
|
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
|
||||||
|
maestro_daemon::SessionManager::new(
|
||||||
|
Arc::new(fx.tmux.tmux.clone()),
|
||||||
|
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||||
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||||
|
fx.fifo_dir(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
||||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
maestro_config::SpawnSection::default().resolve().unwrap(),
|
maestro_config::SpawnSection::default().resolve().unwrap(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
|
maestro_config::DEFAULT_LEADER,
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -123,10 +123,38 @@ fn epoch() -> DateTime<Utc> {
|
|||||||
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
/// Lifecycle of a session as the daemon knows it.
|
||||||
|
///
|
||||||
|
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
|
||||||
|
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
|
||||||
|
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
|
||||||
|
/// one this client must not treat as having a live pane to type into.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
pub enum SessionStatus {
|
pub enum SessionStatus {
|
||||||
Running,
|
Running,
|
||||||
Dead,
|
Dead,
|
||||||
|
/// Known from its sidecar, with no tmux session behind it (#162). The
|
||||||
|
/// daemon registers these at recovery time for every sidecar tmux had
|
||||||
|
/// nothing to adopt — after a host reboot, that is every session there
|
||||||
|
/// was. There is no pane and no PTY bridge: a dormant session is a record
|
||||||
|
/// of what was running, listed so the operator still has it, and the only
|
||||||
|
/// thing that can be done to it is dismissing it.
|
||||||
|
Dormant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SessionStatus {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(match s.as_str() {
|
||||||
|
"Running" => Self::Running,
|
||||||
|
"Dormant" => Self::Dormant,
|
||||||
|
// Known or not, any other spelling degrades to the inert variant.
|
||||||
|
_ => Self::Dead,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||||
@@ -489,8 +517,11 @@ impl ApiError {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SpawnPreset {
|
pub struct SpawnPreset {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resume_command: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provenance of the running daemon: the commit it was compiled
|
/// Build provenance of the running daemon: the commit it was compiled
|
||||||
@@ -554,6 +585,12 @@ pub struct DaemonInfo {
|
|||||||
/// network blip, even when the build did not change.
|
/// network blip, even when the build did not change.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instance: String,
|
pub instance: String,
|
||||||
|
/// Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
/// `ctrl-a`), served so the web client binds the same chord the TUI does
|
||||||
|
/// instead of its own hardcoded constant (#159). Empty from a daemon too
|
||||||
|
/// old to serve it, which clients read as "use your default".
|
||||||
|
#[serde(default)]
|
||||||
|
pub leader: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||||
@@ -1053,6 +1090,33 @@ mod tests {
|
|||||||
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
||||||
serde_json::json!("Dead")
|
serde_json::json!("Dead")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(SessionStatus::Dormant).unwrap(),
|
||||||
|
serde_json::json!("Dormant")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #162: a session with no tmux behind it still rides the wire whole.
|
||||||
|
#[test]
|
||||||
|
fn dormant_session_round_trips() {
|
||||||
|
let mut s = sample_session();
|
||||||
|
s.status = SessionStatus::Dormant;
|
||||||
|
assert_eq!(s, round_trip(&s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #162: a `SessionStatus` this client does not know must degrade to the
|
||||||
|
// inert variant, not fail the whole `Session` the way a derived
|
||||||
|
// `Deserialize` would.
|
||||||
|
#[test]
|
||||||
|
fn unknown_session_status_degrades_to_dead() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
|
||||||
|
SessionStatus::Dead
|
||||||
|
);
|
||||||
|
let mut value = serde_json::to_value(sample_session()).unwrap();
|
||||||
|
value["status"] = serde_json::json!("Hibernating");
|
||||||
|
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
|
||||||
|
assert_eq!(s.status, SessionStatus::Dead);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ pub enum BarOutcome {
|
|||||||
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
||||||
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
||||||
ordered.sort_by(|a, b| {
|
ordered.sort_by(|a, b| {
|
||||||
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
|
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
|
||||||
|
// (#162) — sorts after the sessions you could actually attach to.
|
||||||
|
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
|
||||||
dead(a)
|
dead(a)
|
||||||
.cmp(&dead(b))
|
.cmp(&dead(b))
|
||||||
.then_with(|| {
|
.then_with(|| {
|
||||||
@@ -319,7 +321,7 @@ impl CommandBar {
|
|||||||
.map(|s| BarEntry {
|
.map(|s| BarEntry {
|
||||||
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
||||||
target: BarTarget::Session(s.id),
|
target: BarTarget::Session(s.id),
|
||||||
dead: s.status == SessionStatus::Dead,
|
dead: s.status != SessionStatus::Running,
|
||||||
chords: Vec::new(),
|
chords: Vec::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -889,6 +891,23 @@ mod tests {
|
|||||||
assert!(bar.rows()[1].dead);
|
assert!(bar.rows()[1].dead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
|
||||||
|
/// which way the pane went away — a dormant session sorts with the dead.
|
||||||
|
#[test]
|
||||||
|
fn dormant_sessions_sort_after_live_ones_however_recent() {
|
||||||
|
let mut dormant = named(1, "dormant");
|
||||||
|
dormant.status = SessionStatus::Dormant;
|
||||||
|
dormant.last_focused_at = Some(at(99));
|
||||||
|
let live = named(2, "live");
|
||||||
|
|
||||||
|
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
|
||||||
|
assert_eq!(
|
||||||
|
ids(bar.rows()),
|
||||||
|
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
|
||||||
|
);
|
||||||
|
assert!(bar.rows()[1].dead);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||||||
let mut bar = CommandBar::new(
|
let mut bar = CommandBar::new(
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ impl TuiConfig {
|
|||||||
TuiTheme::Nord => Palette::NORD,
|
TuiTheme::Nord => Palette::NORD,
|
||||||
TuiTheme::Dracula => Palette::DRACULA,
|
TuiTheme::Dracula => Palette::DRACULA,
|
||||||
};
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader();
|
||||||
let leader = InputKey::parse(leader_str)
|
let leader = InputKey::parse(leader_str)
|
||||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
instance: instance.into(),
|
instance: instance.into(),
|
||||||
|
leader: maestro_config::DEFAULT_LEADER.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1705,6 +1705,7 @@ mod tests {
|
|||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
resume_command: None,
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
@@ -1727,11 +1728,13 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "aider".into(),
|
id: "aider".into(),
|
||||||
label: "Aider".into(),
|
label: "Aider".into(),
|
||||||
command: "aider".into(),
|
command: "aider".into(),
|
||||||
|
resume_command: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
@@ -1751,6 +1754,7 @@ mod tests {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
resume_command: None,
|
||||||
}]);
|
}]);
|
||||||
assert!(draft.is_custom());
|
assert!(draft.is_custom());
|
||||||
assert_eq!(draft.command(), "bash -l");
|
assert_eq!(draft.command(), "bash -l");
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ mod tests {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: id.into(),
|
label: id.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
resume_command: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ impl Sidebar {
|
|||||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
(SessionStatus::Dead, _) => "✗",
|
(SessionStatus::Dead, _) => "✗",
|
||||||
|
// #162: known from its sidecar, nothing running behind it.
|
||||||
|
(SessionStatus::Dormant, _) => "◌",
|
||||||
(SessionStatus::Running, true) => "●",
|
(SessionStatus::Running, true) => "●",
|
||||||
(SessionStatus::Running, false) => "○",
|
(SessionStatus::Running, false) => "○",
|
||||||
};
|
};
|
||||||
@@ -322,6 +324,7 @@ impl Sidebar {
|
|||||||
// on line 1; the kill confirm names the specifics.
|
// on line 1; the kill confirm names the specifics.
|
||||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||||
let dead = s.status == SessionStatus::Dead;
|
let dead = s.status == SessionStatus::Dead;
|
||||||
|
let dormant = s.status == SessionStatus::Dormant;
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let fill = self.card_fill(s, focused);
|
let fill = self.card_fill(s, focused);
|
||||||
let base = card_base(fill, focused);
|
let base = card_base(fill, focused);
|
||||||
@@ -361,6 +364,9 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
|
if dormant {
|
||||||
|
tail.push(Span::styled(" dormant", muted));
|
||||||
|
}
|
||||||
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||||
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||||
|
|
||||||
@@ -1068,6 +1074,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
|
||||||
|
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
|
||||||
|
/// it never reads as something that exited.
|
||||||
|
#[test]
|
||||||
|
fn dormant_session_reads_as_known_but_not_running() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.status = SessionStatus::Dormant;
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
pane_session: None,
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
|
assert!(
|
||||||
|
all[idx].contains('\u{25cc}'),
|
||||||
|
"expected the dormant glyph in: {:?}",
|
||||||
|
all[idx]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all[idx + 1].contains("dormant"),
|
||||||
|
"expected dormant fragment in: {:?}",
|
||||||
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!all[idx + 1].contains("exited"),
|
||||||
|
"a dormant session never reads as exited: {:?}",
|
||||||
|
all[idx + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_drops_the_commands_arguments() {
|
fn meta_line_drops_the_commands_arguments() {
|
||||||
|
|||||||
+15
-9
@@ -45,6 +45,12 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
|||||||
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||||
useful.
|
useful.
|
||||||
|
|
||||||
|
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
|
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
|
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
|
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||||
@@ -117,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at
|
|||||||
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
||||||
|
|
||||||
**A session that has never been focused sorts after every session that has**,
|
**A session that has never been focused sorts after every session that has**,
|
||||||
and among those, most recently created first. Dead sessions sort after live
|
and among those, most recently created first. Sessions with no live pane —
|
||||||
ones regardless of recency: a session you cannot attach to is not where you
|
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
|
||||||
just were.
|
session you cannot attach to is not where you just were.
|
||||||
|
|
||||||
Under `>` an empty query lists every command in registry order. Under `~` it
|
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||||
lists recent dirs, most recent first, then the project list.
|
lists recent dirs, most recent first, then the project list.
|
||||||
@@ -152,12 +158,12 @@ searchable — the query line keeps working, now over those actions.
|
|||||||
ambiguous.
|
ambiguous.
|
||||||
- Escape, or backspace at an empty query, returns to the result list **with the
|
- Escape, or backspace at an empty query, returns to the result list **with the
|
||||||
previous query intact**. Drilling in and backing out is free.
|
previous query intact**. Drilling in and backing out is free.
|
||||||
- Each action raises the flow it already has: kill goes through the existing
|
- Each action raises the flow it already has: kill through the confirmation
|
||||||
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
|
both clients ask for (`docs/SIDEBAR.md`) and the blockers dialog behind it
|
||||||
existing prompt pre-filled with the session's own name
|
(`docs/WORKTREES.md`), rename through the existing prompt pre-filled with the
|
||||||
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
|
session's own name (`docs/SIDEBAR.md`), spawn similar through the spawn form
|
||||||
session's directory and command. The bar picks the action; it never
|
seeded from the session's directory and command. The bar picks the action; it
|
||||||
reimplements one.
|
never reimplements one.
|
||||||
|
|
||||||
No actions beyond those three, and no action across more than one session.
|
No actions beyond those three, and no action across more than one session.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -136,7 +136,8 @@ cover every v1 action — only override what you want to change:
|
|||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
theme = "dark" # see the theme list below
|
theme = "dark" # see the theme list below
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode; the web reads
|
||||||
|
# the same setting off /api/info
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
|||||||
moment a probe succeeds — there is no "retry" button, because the client is
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
already retrying.
|
already retrying.
|
||||||
|
|
||||||
|
## What survives, and what comes back dormant
|
||||||
|
|
||||||
|
A daemon restart leaves tmux alone, so recovery re-adopts every
|
||||||
|
`maestro-<uuid>` session tmux still holds and the operator sees no change
|
||||||
|
beyond the reconnect above.
|
||||||
|
|
||||||
|
A **host reboot** is different: it kills the tmux server, so there is nothing
|
||||||
|
to adopt while every session's sidecar is still on disk. Recovery makes a
|
||||||
|
second pass over those sidecars and registers each as a `Dormant` session
|
||||||
|
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
||||||
|
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
||||||
|
resized, and neither watcher pass touches it. It is a record of what was
|
||||||
|
running, and the only thing left to do with one is dismiss it, which removes
|
||||||
|
the sidecar so the next recovery does not bring it back.
|
||||||
|
|
||||||
|
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
|
||||||
|
a spelling this build does not know reads as `Dead` rather than failing the
|
||||||
|
whole session. Clients render a dormant session as known-but-not-running —
|
||||||
|
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
|
||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
|||||||
+26
-5
@@ -56,7 +56,7 @@ itself is not.
|
|||||||
Line 2 reads left to right:
|
Line 2 reads left to right:
|
||||||
|
|
||||||
```
|
```
|
||||||
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
|
||||||
```
|
```
|
||||||
|
|
||||||
Its minimum content is therefore just the command.
|
Its minimum content is therefore just the command.
|
||||||
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
|
|||||||
while an agent-reported state is in force (see Precedence): when the report
|
while an agent-reported state is in force (see Precedence): when the report
|
||||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
|
||||||
client ever lets it wrap.
|
neither client ever lets it wrap.
|
||||||
|
|
||||||
|
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
|
||||||
|
only from its sidecar: recovery found no tmux session behind it, so it has no
|
||||||
|
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
||||||
|
observed ending — so the two fragments never both show. The TUI marks the same
|
||||||
|
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||||
|
|
||||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||||
@@ -209,7 +215,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a
|
|||||||
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||||
step up to.
|
step up to.
|
||||||
|
|
||||||
The duration text inherits its state's colour; the exited and command
|
The duration text inherits its state's colour; the exited, dormant and command
|
||||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||||
take the `active` and `error` roles of the same surface.
|
take the `active` and `error` roles of the same surface.
|
||||||
@@ -316,7 +322,7 @@ Everything else is the same key on both clients:
|
|||||||
| `Tab` | hand the keyboard to the pane |
|
| `Tab` | hand the keyboard to the pane |
|
||||||
| `c` | collapse or expand the highlighted folder |
|
| `c` | collapse or expand the highlighted folder |
|
||||||
| `r` | rename the highlighted session |
|
| `r` | rename the highlighted session |
|
||||||
| `x` | kill the highlighted session |
|
| `x` | kill the highlighted session (confirms) |
|
||||||
| `n` | open the spawn form |
|
| `n` | open the spawn form |
|
||||||
| `N` | spawn with every default resolved |
|
| `N` | spawn with every default resolved |
|
||||||
| `?` | show the shortcut overlay |
|
| `?` | show the shortcut overlay |
|
||||||
@@ -324,3 +330,18 @@ Everything else is the same key on both clients:
|
|||||||
The TUI binds these through its keymap, so a user can rebind any of them; the
|
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||||
web client's are fixed. A binding added to one client belongs in this table and
|
web client's are fixed. A binding added to one client belongs in this table and
|
||||||
in both help overlays.
|
in both help overlays.
|
||||||
|
|
||||||
|
## Killing a session
|
||||||
|
|
||||||
|
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||||
|
and unattended agents live in these sessions, so neither client sends
|
||||||
|
`DELETE /api/sessions/:id` off a single press. The TUI raises
|
||||||
|
`OverlayKind::KillConfirm` (`[y]`/`[n]`); the web raises the same dialog its
|
||||||
|
`✕` button and the command bar's action page both use. Cancel holds the focus
|
||||||
|
in the web's dialog, so nothing kills on a stray Enter.
|
||||||
|
|
||||||
|
The confirmation names what the card last saw in the way, when anything was —
|
||||||
|
the daemon will refuse an unforced kill over such a worktree, and saying so
|
||||||
|
before the attempt is cheaper than a refusal. What comes back from that refusal
|
||||||
|
is a second, separate prompt offering `force=true`; `docs/WORKTREES.md` owns
|
||||||
|
what a forced kill leaves behind.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("kill from the action page", () => {
|
describe("kill from the action page", () => {
|
||||||
it("goes through the daemon, without a first confirmation", async () => {
|
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
@@ -75,6 +75,12 @@ describe("kill from the action page", () => {
|
|||||||
mount(s);
|
mount(s);
|
||||||
raise(KILL_SESSION_EVENT, s);
|
raise(KILL_SESSION_EVENT, s);
|
||||||
await flush();
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
||||||
|
"Kill amber-falcon",
|
||||||
|
);
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -101,6 +107,8 @@ describe("kill from the action page", () => {
|
|||||||
mount(s);
|
mount(s);
|
||||||
raise(KILL_SESSION_EVENT, s);
|
raise(KILL_SESSION_EVENT, s);
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
|||||||
+53
-1
@@ -98,7 +98,7 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills straight away, without a first confirmation", async () => {
|
it("confirms before an unguarded kill (#160)", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
@@ -108,10 +108,45 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([plain])).el;
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
||||||
|
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
||||||
|
blocked_work: { untracked_files: 1 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
|
expect(dialog?.textContent).toContain("refused unless it is forced");
|
||||||
|
});
|
||||||
|
|
||||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
@@ -138,6 +173,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||||
@@ -166,6 +203,8 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
|
await flush();
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||||
@@ -375,6 +414,19 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line2?.textContent).toContain("exited");
|
expect(line2?.textContent).toContain("exited");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #162: a session recovered with no tmux behind it is known but not running,
|
||||||
|
// and never reads as something that exited.
|
||||||
|
it("shows dormant on line 2 for dormant sessions", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
||||||
|
status: "Dormant",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2?.textContent).toContain("dormant");
|
||||||
|
expect(line2?.textContent).not.toContain("exited");
|
||||||
|
});
|
||||||
|
|
||||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||||
|
|||||||
+35
-24
@@ -1,6 +1,6 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openForceKillDialog } from "./killDialog.js";
|
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { toggleHelp } from "./help.js";
|
import { toggleHelp } from "./help.js";
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
@@ -113,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
|||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
function metaLine(s: Session, now: number): string {
|
||||||
const dead = s.status === "Dead";
|
const dead = s.status === "Dead";
|
||||||
|
// #162: known from its sidecar with nothing running behind it — a distinct
|
||||||
|
// fragment, since it never exited.
|
||||||
|
const dormant = s.status === "Dormant";
|
||||||
const blockers = sessionBlockers(s);
|
const blockers = sessionBlockers(s);
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
const held = activityHeldMs(s.activity_since, now);
|
||||||
@@ -127,6 +130,7 @@ function metaLine(s: Session, now: number): string {
|
|||||||
commandFragment(s.command),
|
commandFragment(s.command),
|
||||||
blockers ? blockedFragment(blockers) : "",
|
blockers ? blockedFragment(blockers) : "",
|
||||||
dead ? `<span class="card-flag">exited</span>` : "",
|
dead ? `<span class="card-flag">exited</span>` : "",
|
||||||
|
dormant ? `<span class="card-flag">dormant</span>` : "",
|
||||||
];
|
];
|
||||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||||
}
|
}
|
||||||
@@ -230,7 +234,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void requestKill(cur, sessionName(cur));
|
requestKill(cur, sessionName(cur));
|
||||||
});
|
});
|
||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||||
@@ -248,7 +252,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
{
|
{
|
||||||
label: "Kill session",
|
label: "Kill session",
|
||||||
danger: true,
|
danger: true,
|
||||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
onSelect: () => requestKill(cur, sessionName(cur)),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -293,26 +297,33 @@ function treeSignature(node: TreeNode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
||||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
* agents live in these sessions, so the web asks first, the way the TUI always
|
||||||
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
||||||
* a forced kill. Anything else surfaces as an alert.
|
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
||||||
|
* a forced kill. Anything else surfaces in the dialog that asked.
|
||||||
*/
|
*/
|
||||||
async function requestKill(s: Session, name: string): Promise<void> {
|
function requestKill(s: Session, name: string): void {
|
||||||
try {
|
openKillConfirmDialog({
|
||||||
await killSession(s.id);
|
name,
|
||||||
} catch (e) {
|
blockers: sessionBlockers(s),
|
||||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
onKill: async () => {
|
||||||
openForceKillDialog({
|
try {
|
||||||
name,
|
await killSession(s.id);
|
||||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
} catch (e) {
|
||||||
reason: e.detail,
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||||
onForce: () => killSession(s.id, true),
|
openForceKillDialog({
|
||||||
});
|
name,
|
||||||
return;
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||||
}
|
reason: e.detail,
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
onForce: () => killSession(s.id, true),
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -719,7 +730,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
"list.kill": () => {
|
"list.kill": () => {
|
||||||
const hit = highlightedSession();
|
const hit = highlightedSession();
|
||||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
if (hit) requestKill(hit.session, sessionName(hit.session));
|
||||||
},
|
},
|
||||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||||
}),
|
}),
|
||||||
@@ -782,7 +793,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// than by row.
|
// than by row.
|
||||||
const killHandler = (ev: Event) => {
|
const killHandler = (ev: Event) => {
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
void requestKill(s, sessionName(s));
|
requestKill(s, sessionName(s));
|
||||||
};
|
};
|
||||||
const renameHandler = (ev: Event) => {
|
const renameHandler = (ev: Event) => {
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
|
||||||
|
// way the pane went away.
|
||||||
|
it("sorts dormant sessions after live ones, like dead ones", () => {
|
||||||
|
const dormantRecent = session("dormant", {
|
||||||
|
status: "Dormant",
|
||||||
|
last_focused_at: "2026-06-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||||
|
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
|
||||||
|
"live",
|
||||||
|
"dormant",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a null last_focused_at as never focused", () => {
|
it("treats a null last_focused_at as never focused", () => {
|
||||||
const nulled = session("nulled", { last_focused_at: null });
|
const nulled = session("nulled", { last_focused_at: null });
|
||||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
|
|||||||
* matcher's last tie-break preserves. */
|
* matcher's last tie-break preserves. */
|
||||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||||
return [...sessions].sort((a, b) => {
|
return [...sessions].sort((a, b) => {
|
||||||
const deadA = a.status === "Dead" ? 1 : 0;
|
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
||||||
const deadB = b.status === "Dead" ? 1 : 0;
|
// — sorts after the sessions you could actually attach to.
|
||||||
|
const deadA = a.status === "Running" ? 0 : 1;
|
||||||
|
const deadB = b.status === "Running" ? 0 : 1;
|
||||||
if (deadA !== deadB) return deadA - deadB;
|
if (deadA !== deadB) return deadA - deadB;
|
||||||
|
|
||||||
const focusA = focusedAt(a);
|
const focusA = focusedAt(a);
|
||||||
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
row.dataset.kind = candidate.kind;
|
row.dataset.kind = candidate.kind;
|
||||||
if (candidate.kind === "session") {
|
if (candidate.kind === "session") {
|
||||||
row.dataset.sessionId = candidate.session.id;
|
row.dataset.sessionId = candidate.session.id;
|
||||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
if (candidate.session.status !== "Running") row.classList.add("is-dead");
|
||||||
} else if (isActionCandidate(candidate)) {
|
} else if (isActionCandidate(candidate)) {
|
||||||
row.dataset.actionId = candidate.action.id;
|
row.dataset.actionId = candidate.action.id;
|
||||||
} else if (candidate.kind === "command") {
|
} else if (candidate.kind === "command") {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
COMMAND_SPECS,
|
COMMAND_SPECS,
|
||||||
CommandSet,
|
CommandSet,
|
||||||
|
commandSpecs,
|
||||||
DEFAULT_LEADER,
|
DEFAULT_LEADER,
|
||||||
canonicalizeKey,
|
canonicalizeKey,
|
||||||
controlByte,
|
controlByte,
|
||||||
@@ -165,6 +166,37 @@ describe("the leader", () => {
|
|||||||
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #159: the leader is the operator's, served on `/api/info`. Every chord
|
||||||
|
// behind it is built from it, so a rebound leader carries its escape hatches
|
||||||
|
// with it instead of leaving them on a stale ctrl-a.
|
||||||
|
it("builds the chords behind it from the configured leader", () => {
|
||||||
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||||
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
|
// Commands that do not hang off the leader are untouched.
|
||||||
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches a configured leader's chord and not the default's", () => {
|
||||||
|
const literalCtrlK = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), {
|
||||||
|
leader: "ctrl-b",
|
||||||
|
});
|
||||||
|
set.handle(key("b", { ctrlKey: true }));
|
||||||
|
expect(set.handle(key("k", { ctrlKey: true }))).toBe(true);
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The old leader is an ordinary key again: it holds nothing, and the chord
|
||||||
|
// that used to follow it runs nothing.
|
||||||
|
set.handle(key("a", { ctrlKey: true }));
|
||||||
|
set.handle(key("k", { ctrlKey: true }));
|
||||||
|
expect(literalCtrlK).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the configured leader's own byte for the doubled chord", () => {
|
||||||
|
expect(controlByte("ctrl-b")).toBe("\x02");
|
||||||
|
});
|
||||||
|
|
||||||
it("holds the leader as a partial chord instead of selecting the page", () => {
|
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||||
leader: DEFAULT_LEADER,
|
leader: DEFAULT_LEADER,
|
||||||
|
|||||||
+31
-9
@@ -32,13 +32,26 @@ export interface Command extends CommandSpec {
|
|||||||
run: () => void;
|
run: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
/** Leader used when the daemon does not name one — an older daemon, or an
|
||||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
* unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a`
|
||||||
|
* is select-all in a browser, so a set that owns the leader
|
||||||
|
* `preventDefault`s it. */
|
||||||
export const DEFAULT_LEADER = "ctrl-a";
|
export const DEFAULT_LEADER = "ctrl-a";
|
||||||
|
|
||||||
/** Every command the web client has. The single list this issue exists to
|
/** Every command the web client has, for one leader. The single list: labels,
|
||||||
* create: labels, chords and grouping live here and nowhere else. */
|
* chords and grouping live here and nowhere else. Chords behind the leader are
|
||||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
* built from it (#159), so the configured leader — served on `/api/info` as
|
||||||
|
* `leader` — is what the overlay renders and the sets dispatch. */
|
||||||
|
export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] {
|
||||||
|
return SPECS.map(({ leaderKeys, ...spec }) =>
|
||||||
|
leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spec whose chords are fixed, or a spec that builds them from the leader. */
|
||||||
|
type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] };
|
||||||
|
|
||||||
|
const SPECS: readonly SpecSource[] = [
|
||||||
{
|
{
|
||||||
id: "spawn.open",
|
id: "spawn.open",
|
||||||
label: "Open the spawn form",
|
label: "Open the spawn form",
|
||||||
@@ -65,6 +78,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
label: "Send a literal Ctrl-K to the pane",
|
label: "Send a literal Ctrl-K to the pane",
|
||||||
group: "Pane",
|
group: "Pane",
|
||||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ctrl-k`],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,6 +86,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
label: "Send a literal leader key to the pane",
|
label: "Send a literal leader key to the pane",
|
||||||
group: "Pane",
|
group: "Pane",
|
||||||
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||||
|
leaderKeys: (leader) => [`${leader} ${leader}`],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,13 +175,20 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
/** The command list under the default leader. Iteration order everywhere else
|
||||||
|
* (the help overlay, the bar) comes from here. */
|
||||||
|
export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs();
|
||||||
|
|
||||||
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
||||||
* test failure rather than a key that silently does nothing. */
|
* test failure rather than a key that silently does nothing. `leader` picks
|
||||||
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
* the chords behind the leader; omit it for a set that has none. */
|
||||||
|
export function defineCommands(
|
||||||
|
handlers: Record<string, () => void>,
|
||||||
|
leader: string = DEFAULT_LEADER,
|
||||||
|
): Command[] {
|
||||||
|
const byId = new Map(commandSpecs(leader).map((s) => [s.id, s]));
|
||||||
return Object.entries(handlers).map(([id, run]) => {
|
return Object.entries(handlers).map(([id, run]) => {
|
||||||
const spec = SPEC_BY_ID.get(id);
|
const spec = byId.get(id);
|
||||||
if (!spec) throw new Error(`unknown command id: ${id}`);
|
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||||
return { ...spec, run };
|
return { ...spec, run };
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-1
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { keyCaps, publishedCommands } from "./commands.js";
|
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
|
||||||
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,6 +44,33 @@ describe("help overlay", () => {
|
|||||||
.toEqual(publishedCommands().map((c) => c.id));
|
.toEqual(publishedCommands().map((c) => c.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #159: the leader comes from the daemon, so the overlay must render the
|
||||||
|
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
|
||||||
|
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
|
||||||
|
const bound = defineCommands(
|
||||||
|
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
|
||||||
|
"ctrl-b",
|
||||||
|
);
|
||||||
|
publishCommands(bound);
|
||||||
|
try {
|
||||||
|
openHelp();
|
||||||
|
const keysOf = (id: string) =>
|
||||||
|
[
|
||||||
|
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
|
||||||
|
.querySelectorAll("kbd"),
|
||||||
|
]
|
||||||
|
.map((k) => k.textContent)
|
||||||
|
.join(" ");
|
||||||
|
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
|
||||||
|
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
|
||||||
|
} finally {
|
||||||
|
// Leave the registry as the rest of the suite expects it.
|
||||||
|
publishCommands(
|
||||||
|
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("opens on ? and closes on a second ?", () => {
|
it("opens on ? and closes on a second ?", () => {
|
||||||
press("?", { shiftKey: true });
|
press("?", { shiftKey: true });
|
||||||
expect(isHelpOpen()).toBe(true);
|
expect(isHelpOpen()).toBe(true);
|
||||||
|
|||||||
+83
-22
@@ -1,14 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Forced-kill prompt (#81).
|
* The two kill prompts (#81, #160).
|
||||||
*
|
*
|
||||||
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
* A kill is destructive and unattended agents live in these sessions, so both
|
||||||
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
* clients ask before one goes through — the TUI's `KillConfirm`, and
|
||||||
* refusal gets its own dialog: it names what is in the way and offers the
|
* `openKillConfirmDialog` here (#160). Where the session's worktree still
|
||||||
* override the daemon's `force=true` already implements. A kill the daemon
|
* holds uncommitted or unpushed work, the confirmation says so up front, the
|
||||||
* accepts never reaches here — there is nothing to warn about.
|
* same way the TUI's does.
|
||||||
|
*
|
||||||
|
* The daemon decides the rest: it refuses a kill over such a worktree, and
|
||||||
|
* that refusal needs an answer the confirmation cannot give — a forced kill.
|
||||||
|
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
|
||||||
|
* reaches it.
|
||||||
*/
|
*/
|
||||||
import { formatInTheWay, type InTheWay } from "./types.js";
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||||
|
|
||||||
|
/** What both prompts need: a target to name and a button that acts on it. */
|
||||||
|
interface KillDialogSpec {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** Sentence under the title. */
|
||||||
|
body: string;
|
||||||
|
/** Label on the acting button. */
|
||||||
|
confirmLabel: string;
|
||||||
|
/** Extra class on the acting button, so tests and styles can find it. */
|
||||||
|
confirmClass: string;
|
||||||
|
/** Run it. A rejection is rendered in the dialog, which stays open so the
|
||||||
|
* press can be retried. */
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillConfirmPrompt {
|
||||||
|
name: string;
|
||||||
|
/** What the card last saw in the way, when anything was. Shown before the
|
||||||
|
* attempt, so "this will be refused" is not a surprise. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Run the plain kill. Opening a further dialog from here is allowed: the
|
||||||
|
* confirmation only closes itself if it is still the one on screen. */
|
||||||
|
onKill: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForceKillPrompt {
|
export interface ForceKillPrompt {
|
||||||
/** Session name, as the sidebar shows it. */
|
/** Session name, as the sidebar shows it. */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,9 +60,9 @@ export function closeForceKillDialog(): void {
|
|||||||
open = null;
|
open = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
/** Mount one of the prompts. Opening while one is up replaces it, so the
|
||||||
* can never stack two dialogs. */
|
* confirmation and a refusal that follows it can never stack. */
|
||||||
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
function mount(p: KillDialogSpec): HTMLElement {
|
||||||
closeForceKillDialog();
|
closeForceKillDialog();
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
@@ -50,9 +80,7 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
|
|
||||||
const body = document.createElement("p");
|
const body = document.createElement("p");
|
||||||
body.className = "kill-dialog-body";
|
body.className = "kill-dialog-body";
|
||||||
body.textContent = p.blockers
|
body.textContent = p.body;
|
||||||
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
|
||||||
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
|
||||||
|
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
error.className = "kill-dialog-error";
|
error.className = "kill-dialog-error";
|
||||||
@@ -65,22 +93,27 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||||
cancel.textContent = "Cancel";
|
cancel.textContent = "Cancel";
|
||||||
cancel.addEventListener("click", closeForceKillDialog);
|
cancel.addEventListener("click", closeForceKillDialog);
|
||||||
const force = document.createElement("button");
|
const confirm = document.createElement("button");
|
||||||
force.type = "button";
|
confirm.type = "button";
|
||||||
force.className = "btn btn-danger kill-dialog-force";
|
confirm.className = `btn btn-danger ${p.confirmClass}`;
|
||||||
force.textContent = "Force kill";
|
confirm.textContent = p.confirmLabel;
|
||||||
force.addEventListener("click", () => {
|
confirm.addEventListener("click", () => {
|
||||||
force.disabled = true;
|
confirm.disabled = true;
|
||||||
void p
|
void p
|
||||||
.onForce()
|
.onConfirm()
|
||||||
.then(closeForceKillDialog)
|
.then(() => {
|
||||||
|
// A handler that opened the refusal prompt has already replaced this
|
||||||
|
// dialog; closing here would take that one down with it.
|
||||||
|
if (open?.el === el) closeForceKillDialog();
|
||||||
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
force.disabled = false;
|
if (open?.el !== el) return;
|
||||||
|
confirm.disabled = false;
|
||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
actions.append(cancel, force);
|
actions.append(cancel, confirm);
|
||||||
|
|
||||||
panel.append(title, body, error, actions);
|
panel.append(title, body, error, actions);
|
||||||
|
|
||||||
@@ -99,6 +132,34 @@ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeydown, true);
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
open = { el, onKeydown };
|
open = { el, onKeydown };
|
||||||
|
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
||||||
|
// away from whatever asked for the dialog.
|
||||||
cancel.focus();
|
cancel.focus();
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask before a kill the daemon has not seen yet (#160). */
|
||||||
|
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
|
||||||
|
: "The session and everything running in it go away. Its worktree stays on disk.",
|
||||||
|
confirmLabel: "Kill",
|
||||||
|
confirmClass: "kill-dialog-kill",
|
||||||
|
onConfirm: p.onKill,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer the override after the daemon refuses a guarded kill (#81). */
|
||||||
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||||
|
return mount({
|
||||||
|
name: p.name,
|
||||||
|
body: p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
|
||||||
|
confirmLabel: "Force kill",
|
||||||
|
confirmClass: "kill-dialog-force",
|
||||||
|
onConfirm: p.onForce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+54
-28
@@ -87,9 +87,15 @@ const store = new CatalogStore();
|
|||||||
// Absolute paths are a fine fallback until it lands.
|
// Absolute paths are a fine fallback until it lands.
|
||||||
let homeDir: string | undefined;
|
let homeDir: string | undefined;
|
||||||
void getDaemonInfo()
|
void getDaemonInfo()
|
||||||
.then((info) => (homeDir = info.home_dir))
|
.then((info) => {
|
||||||
|
homeDir = info.home_dir;
|
||||||
|
// The leader is the operator's, not this module's (#159): a daemon that
|
||||||
|
// names one rebinds the chords hanging off it, and the `?` overlay and the
|
||||||
|
// command bar re-render from the rebuilt set.
|
||||||
|
if (info.leader) useLeader(info.leader);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* absolute paths are a fine fallback */
|
/* absolute paths and the default leader are fine fallbacks */
|
||||||
});
|
});
|
||||||
|
|
||||||
// The daemon's identity and liveness: names the release in the sidebar's status
|
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||||
@@ -439,32 +445,52 @@ const spawnKeys = new CommandSet(
|
|||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||||
// the focus back out of it.
|
// the focus back out of it.
|
||||||
//
|
//
|
||||||
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||||
// key as a partial chord and takes select-all off it, so the escape hatch
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
|
||||||
const focusKeys = new CommandSet(
|
// (#149).
|
||||||
defineCommands({
|
let currentLeader = DEFAULT_LEADER;
|
||||||
"focus.toggle": () => {
|
|
||||||
if (sidebar.hasListFocus()) {
|
function buildFocusKeys(leader: string): CommandSet {
|
||||||
pool.focusTerminal();
|
return new CommandSet(
|
||||||
} else {
|
defineCommands(
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
{
|
||||||
sidebar.focusList();
|
"focus.toggle": () => {
|
||||||
}
|
if (sidebar.hasListFocus()) {
|
||||||
},
|
pool.focusTerminal();
|
||||||
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
|
} else {
|
||||||
// away from every pane, and leader ctrl-k gives it back as the literal
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
sidebar.focusList();
|
||||||
// bar's, so the chord never opens the bar.
|
}
|
||||||
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
},
|
||||||
// The leader itself, pressed twice, tmux-style: sends one literal leader
|
// The escape hatch: binding ctrl-k takes readline's
|
||||||
// byte and clears the pending chord instead of resolving as a dead chord
|
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
|
||||||
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a
|
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
|
||||||
// hardcoded byte, so a different leader would send its own.
|
// this set, ahead of the bar's, so the chord never opens the bar.
|
||||||
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)),
|
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||||
}),
|
// The leader itself, pressed twice, tmux-style: sends one literal
|
||||||
{ leader: DEFAULT_LEADER },
|
// leader byte and clears the pending chord instead of resolving as a
|
||||||
);
|
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
|
||||||
|
// hardcoded byte, so a rebound leader sends its own.
|
||||||
|
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
|
||||||
|
},
|
||||||
|
leader,
|
||||||
|
),
|
||||||
|
{ leader },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
|
||||||
|
|
||||||
|
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
|
||||||
|
* same command ids replaces them, so the help overlay and the command bar —
|
||||||
|
* both of which render the published registry when they open — show the
|
||||||
|
* configured chord rather than the default. */
|
||||||
|
function useLeader(leader: string): void {
|
||||||
|
if (leader === currentLeader) return;
|
||||||
|
currentLeader = leader;
|
||||||
|
focusKeys = buildFocusKeys(leader);
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||||
// like any other, so the `?` overlay lists it without a second list.
|
// like any other, so the `?` overlay lists it without a second list.
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("x kills the highlighted session", async () => {
|
it("x kills the highlighted session, once confirmed (#160)", async () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
const killed: string[] = [];
|
const killed: string[] = [];
|
||||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
@@ -153,6 +153,8 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
|
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
press(tree(el), "x");
|
press(tree(el), "x");
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-1
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
|
|||||||
export type Uuid = string;
|
export type Uuid = string;
|
||||||
export type IsoTimestamp = string;
|
export type IsoTimestamp = string;
|
||||||
|
|
||||||
export type SessionStatus = "Running" | "Dead";
|
/** `Dormant` (#162) is a session the daemon knows only from its sidecar —
|
||||||
|
* recovered after the tmux server died under it, so there is no pane behind it
|
||||||
|
* and nothing to attach to. Listed, never live. */
|
||||||
|
export type SessionStatus = "Running" | "Dead" | "Dormant";
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
@@ -98,6 +101,10 @@ export interface DaemonInfo {
|
|||||||
* value across a reconnect means the daemon restarted. Absent on older
|
* value across a reconnect means the daemon restarted. Absent on older
|
||||||
* daemons, which the restart flow treats as "cannot tell". */
|
* daemons, which the restart flow treats as "cannot tell". */
|
||||||
instance?: string;
|
instance?: string;
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`, default
|
||||||
|
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
|
||||||
|
* empty on an older daemon; the client then keeps its own default. */
|
||||||
|
leader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build provenance of the running daemon: what the status indicator names in
|
/** Build provenance of the running daemon: what the status indicator names in
|
||||||
|
|||||||
Reference in New Issue
Block a user