(mut sessions: Vec, socket: P) -> anyhow::Result<()>
where
P: AsRef,
{
- let mut client = match protocol::Client::new(socket) {
- Ok(ClientResult::JustClient(c)) => c,
- Ok(ClientResult::VersionMismatch { warning, client }) => {
- eprintln!("warning: {warning}, try restarting your daemon");
- client
- }
- Err(err) => {
- let io_err = err.downcast::()?;
- if io_err.kind() == io::ErrorKind::NotFound {
- eprintln!("could not connect to daemon");
- }
- return Err(io_err).context("connecting to daemon");
- }
- };
+ let mut client = protocol::connect_cli(socket)?;
common::resolve_sessions(&mut sessions, "kill")?;
diff --git a/libshpool/src/lib.rs b/libshpool/src/lib.rs
index 354ba851..5b6dc640 100644
--- a/libshpool/src/lib.rs
+++ b/libshpool/src/lib.rs
@@ -44,6 +44,7 @@ mod session_restore;
mod set_log_level;
mod test_hooks;
mod tty;
+mod tui;
mod user;
/// The command line arguments that shpool expects.
@@ -209,6 +210,10 @@ needs debugging, but would be clobbered by a restart.")]
#[clap(help = "new log level")]
level: shpool_protocol::LogLevel,
},
+
+ #[clap(about = "TUI session manager")]
+ #[non_exhaustive]
+ Tui {},
}
impl Args {
@@ -381,6 +386,13 @@ pub fn run(args: Args, hooks: Option>) -> an
Commands::Kill { sessions } => kill::run(sessions, socket),
Commands::List { json } => list::run(socket, json),
Commands::SetLogLevel { level } => set_log_level::run(level, socket),
+ Commands::Tui {} => {
+ // Forward the parent invocation's top-level flags so the
+ // `shpool attach` children spawned from the TUI see the
+ // same config / log destination / verbosity. See
+ // tui::run's docstring.
+ tui::run(socket, args.config_file.clone(), args.log_file.clone(), args.verbose)
+ }
};
if let Err(err) = res {
diff --git a/libshpool/src/list.rs b/libshpool/src/list.rs
index 2492ab05..e6b61d56 100644
--- a/libshpool/src/list.rs
+++ b/libshpool/src/list.rs
@@ -12,29 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-use std::{io, path::PathBuf, time};
+use std::{path::PathBuf, time};
use anyhow::Context;
use chrono::{DateTime, Utc};
use shpool_protocol::{ConnectHeader, ListReply};
-use crate::{protocol, protocol::ClientResult};
+use crate::protocol;
pub fn run(socket: PathBuf, json_output: bool) -> anyhow::Result<()> {
- let mut client = match protocol::Client::new(socket) {
- Ok(ClientResult::JustClient(c)) => c,
- Ok(ClientResult::VersionMismatch { warning, client }) => {
- eprintln!("warning: {warning}, try restarting your daemon");
- client
- }
- Err(err) => {
- let io_err = err.downcast::()?;
- if io_err.kind() == io::ErrorKind::NotFound {
- eprintln!("could not connect to daemon");
- }
- return Err(io_err).context("connecting to daemon");
- }
- };
+ let mut client = protocol::connect_cli(socket)?;
client.write_connect_header(ConnectHeader::List).context("sending list connect header")?;
let reply: ListReply = client.read_reply().context("reading reply")?;
diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs
index 6051db8e..6a0c6a00 100644
--- a/libshpool/src/protocol.rs
+++ b/libshpool/src/protocol.rs
@@ -430,6 +430,35 @@ impl Client {
}
}
+/// Connect to the shpool daemon, returning the Client and any
+/// version-mismatch warning the caller should surface to the user.
+pub fn connect(socket: impl AsRef) -> anyhow::Result<(Client, Option)> {
+ match Client::new(socket)? {
+ ClientResult::JustClient(c) => Ok((c, None)),
+ ClientResult::VersionMismatch { warning, client } => Ok((client, Some(warning))),
+ }
+}
+
+/// CLI-style connect: warnings go to stderr, NotFound produces the
+/// standard "could not connect to daemon" hint, and the wrapped error
+/// is returned with a "connecting to daemon" context.
+pub fn connect_cli(socket: impl AsRef) -> anyhow::Result {
+ match connect(socket) {
+ Ok((client, None)) => Ok(client),
+ Ok((client, Some(warning))) => {
+ eprintln!("warning: {warning}, try restarting your daemon");
+ Ok(client)
+ }
+ Err(err) => {
+ let io_err = err.downcast::()?;
+ if io_err.kind() == io::ErrorKind::NotFound {
+ eprintln!("could not connect to daemon");
+ }
+ Err(io_err).context("connecting to daemon")
+ }
+ }
+}
+
#[cfg(test)]
mod test {
use super::*;
diff --git a/libshpool/src/tui/attach.rs b/libshpool/src/tui/attach.rs
new file mode 100644
index 00000000..e51feffb
--- /dev/null
+++ b/libshpool/src/tui/attach.rs
@@ -0,0 +1,205 @@
+//! Spawn `shpool attach [-f] ` as a child process.
+//!
+//! We shell out (to ourselves, via `argv[0]`) rather than call
+//! [`crate::attach::run`] in-process because attach owns the terminal
+//! in raw mode and spawns its own stdin/stdout threads — see
+//! libshpool/src/protocol.rs pipe_bytes(). A subprocess boundary is
+//! the simplest way to make sure that all cleans up before the TUI
+//! reclaims the screen.
+
+use std::{
+ env,
+ path::PathBuf,
+ process::{Command, Stdio},
+};
+
+use anyhow::{anyhow, Context, Result};
+
+/// Parent-invocation flags to forward to the spawned `shpool attach`.
+///
+/// When the user runs `shpool --config-file foo.toml tui`, the spawned
+/// attaches need to see the same `--config-file foo.toml` so the
+/// attached shell picks up the right config (prompt template, hooks,
+/// TTL defaults, etc.). Same reasoning for `--log-file` and `-v`.
+/// `--socket` lives here too so every child talks to the same daemon.
+///
+/// What's deliberately *not* forwarded: `--daemonize`. The spawned
+/// attach is always given `--no-daemonize`, even if the parent
+/// `shpool tui` was launched with `--daemonize` on. Rationale:
+/// silently auto-spawning a daemon from inside an alt-screen attach
+/// is worse UX than a visible "daemon's gone, please restart" error,
+/// and `shpool tui`'s framing is "manage existing sessions" —
+/// starting new infrastructure mid-session doesn't fit. The CLI-flag
+/// asymmetry here is small and deliberate.
+#[derive(Debug)]
+pub struct AttachEnv {
+ pub socket: PathBuf,
+ pub config_file: Option,
+ pub log_file: Option,
+ pub verbose: u8,
+}
+
+/// Spawn `shpool attach [-f] ` and block until it exits.
+///
+/// The caller (suspend.rs) is responsible for putting the terminal
+/// back in cooked mode and leaving the alt screen first. Here we
+/// just fork+exec+wait.
+///
+/// Returns `true` if the child exited cleanly (status 0), `false`
+/// otherwise. Errors are reserved for "we couldn't even spawn it".
+pub fn spawn_attach(name: &str, force: bool, env: &AttachEnv) -> Result {
+ // Use argv[0] of the current process rather than a hardcoded
+ // "shpool". This matters for:
+ // 1. Running from a non-installed build (cargo run) where `shpool` on PATH
+ // might be an older system install.
+ // 2. Tests that invoke the binary with an explicit path.
+ // 3. Any packaging that names the binary differently.
+ //
+ // libshpool already does this in daemonize::maybe_fork_daemon,
+ // so we're consistent with the rest of the crate.
+ let arg0 = env::args()
+ .next()
+ .ok_or_else(|| anyhow!("argv[0] missing — cannot find shpool binary to re-exec"))?;
+
+ let mut cmd = build_command(&arg0, name, force, env);
+
+ // Inherit stdin/stdout/stderr so the child owns the terminal.
+ // (This is the default for Command, but being explicit prevents
+ // future confusion if someone adds `.stdout(Stdio::piped())`
+ // thinking it's harmless.)
+ cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
+
+ let status = cmd.status().context("spawning `shpool attach`")?;
+ Ok(status.success())
+}
+
+/// Build the `shpool attach` Command, including all forwarded flags.
+///
+/// Split out from `spawn_attach` so we can unit-test the arg list
+/// without actually fork+exec'ing. stdio inheritance is applied by
+/// `spawn_attach`; this function does not touch it.
+fn build_command(arg0: &str, name: &str, force: bool, env: &AttachEnv) -> Command {
+ let mut cmd = Command::new(arg0);
+
+ // Forward top-level invocation flags so the child behaves the
+ // same as the parent `shpool tui` was launched with:
+ // --config-file: the child re-reads config for shell setup
+ // (prompt prefix, hooks, TTL defaults). Without
+ // forwarding, a user who ran
+ // `shpool --config-file custom.toml tui` sees
+ // attached sessions using the *default* config.
+ // --log-file: match the parent's log destination, else the
+ // child's diagnostics go nowhere useful.
+ // -v (xN): match the parent's verbosity.
+ // --socket: always required — the child has to talk to the
+ // same daemon we're managing.
+ // --no-daemonize: always forced; see the AttachEnv docstring.
+ if let Some(path) = &env.config_file {
+ cmd.arg("--config-file").arg(path);
+ }
+ if let Some(path) = &env.log_file {
+ cmd.arg("--log-file").arg(path);
+ }
+ for _ in 0..env.verbose {
+ cmd.arg("-v");
+ }
+ cmd.arg("--socket").arg(&env.socket).arg("--no-daemonize").arg("attach");
+ if force {
+ cmd.arg("-f");
+ }
+ cmd.arg(name);
+ cmd
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args_of(cmd: &Command) -> Vec {
+ cmd.get_args().map(|s| s.to_string_lossy().into_owned()).collect()
+ }
+
+ fn base_env() -> AttachEnv {
+ AttachEnv {
+ socket: PathBuf::from("/tmp/shpool.sock"),
+ config_file: None,
+ log_file: None,
+ verbose: 0,
+ }
+ }
+
+ #[test]
+ fn build_command_minimal() {
+ let env = base_env();
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ assert_eq!(args, vec!["--socket", "/tmp/shpool.sock", "--no-daemonize", "attach", "main"],);
+ }
+
+ #[test]
+ fn build_command_with_force() {
+ let env = base_env();
+ let args = args_of(&build_command("shpool", "main", true, &env));
+ // -f comes after the `attach` subcommand; name is last.
+ assert!(
+ args.windows(2).any(|w| w[0] == "attach" && w[1] == "-f"),
+ "expected `attach -f` pair; got {args:?}",
+ );
+ assert_eq!(args.last().map(String::as_str), Some("main"));
+ }
+
+ #[test]
+ fn build_command_forwards_config_file() {
+ let mut env = base_env();
+ env.config_file = Some("/etc/shpool/custom.toml".into());
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ assert!(
+ args.windows(2).any(|w| w[0] == "--config-file" && w[1] == "/etc/shpool/custom.toml"),
+ "expected --config-file with path; got {args:?}",
+ );
+ }
+
+ #[test]
+ fn build_command_forwards_log_file() {
+ let mut env = base_env();
+ env.log_file = Some("/var/log/shpool.log".into());
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ assert!(
+ args.windows(2).any(|w| w[0] == "--log-file" && w[1] == "/var/log/shpool.log"),
+ "expected --log-file with path; got {args:?}",
+ );
+ }
+
+ #[test]
+ fn build_command_forwards_verbose_count() {
+ let mut env = base_env();
+ env.verbose = 3;
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ assert_eq!(args.iter().filter(|a| *a == "-v").count(), 3);
+ }
+
+ #[test]
+ fn build_command_no_verbose_when_zero() {
+ let env = base_env();
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ assert!(!args.iter().any(|a| a == "-v"));
+ }
+
+ #[test]
+ fn build_command_global_flags_precede_subcommand() {
+ // Clap requires global flags (those declared on `Args`) to
+ // appear before the subcommand (`attach`). Check the ordering
+ // holds when every forwardable flag is set.
+ let env = AttachEnv {
+ socket: PathBuf::from("/tmp/s"),
+ config_file: Some("/c".into()),
+ log_file: Some("/l".into()),
+ verbose: 1,
+ };
+ let args = args_of(&build_command("shpool", "main", false, &env));
+ let attach_pos = args.iter().position(|a| a == "attach").expect("attach present");
+ for flag in ["--config-file", "--log-file", "-v", "--socket", "--no-daemonize"] {
+ let pos = args.iter().position(|a| a == flag).expect(flag);
+ assert!(pos < attach_pos, "{flag} should come before `attach`; got {args:?}");
+ }
+ }
+}
diff --git a/libshpool/src/tui/command.rs b/libshpool/src/tui/command.rs
new file mode 100644
index 00000000..b5159f8e
--- /dev/null
+++ b/libshpool/src/tui/command.rs
@@ -0,0 +1,30 @@
+//! `Command` — side effects the executor carries out after `update`.
+
+/// A side effect the main loop should perform. Produced by `update`,
+/// consumed by `mod.rs::execute`.
+#[derive(Debug, PartialEq)]
+pub enum Command {
+ /// Refetch the session list from the daemon. Results come back as
+ /// [`super::event::Event::SessionsRefreshed`] or
+ /// [`super::event::Event::RefreshFailed`].
+ Refresh,
+
+ /// Spawn `shpool attach [-f] ` as a child process, suspend
+ /// the TUI while it runs, and resume when it exits. Result comes
+ /// back as [`super::event::Event::AttachExited`].
+ Attach { name: String, force: bool },
+
+ /// Like Attach but creates a brand-new session. In shpool this
+ /// is the same daemon call as Attach (the daemon create-or-
+ /// attaches), but we keep them distinct so update/view can
+ /// enforce "name must not already exist" vs "name must already
+ /// exist" pre-flight checks.
+ Create(String),
+
+ /// Kill the named session via the shpool protocol. Result comes
+ /// back as [`super::event::Event::KillFinished`].
+ Kill(String),
+
+ /// Stop the main loop. No follow-up event.
+ Quit,
+}
diff --git a/libshpool/src/tui/event.rs b/libshpool/src/tui/event.rs
new file mode 100644
index 00000000..62ca6c41
--- /dev/null
+++ b/libshpool/src/tui/event.rs
@@ -0,0 +1,35 @@
+//! The `Event` type — everything the `update` function can react to.
+
+use crossterm::event::KeyEvent;
+use shpool_protocol::Session;
+
+/// Things that can happen to the TUI. The `update` function turns
+/// each of these into a model mutation (and optionally a `Command`
+/// for the executor to carry out).
+#[derive(Debug)]
+pub enum Event {
+ /// A keystroke from crossterm. Wraps crossterm's own KeyEvent
+ /// directly — `update` pattern-matches on key code + modifiers.
+ Key(KeyEvent),
+
+ /// The terminal window regained focus. Triggers a refresh: if
+ /// the user switched away and came back, session state may have
+ /// changed while they weren't looking.
+ FocusGained,
+
+ /// The daemon answered a List request; here's the fresh data.
+ SessionsRefreshed(Vec),
+
+ /// The daemon list request failed. The string is a display-ready
+ /// error message for the UI layer to surface.
+ RefreshFailed(String),
+
+ /// A child `shpool attach` process returned control to us. The
+ /// `bool` is whether it exited cleanly — false means we should
+ /// surface an error.
+ AttachExited { ok: bool, name: String },
+
+ /// A kill request to the daemon finished. Like AttachExited, we
+ /// report failure as an error in the footer.
+ KillFinished { ok: bool, name: String, err: Option },
+}
diff --git a/libshpool/src/tui/keymap.rs b/libshpool/src/tui/keymap.rs
new file mode 100644
index 00000000..395e04c3
--- /dev/null
+++ b/libshpool/src/tui/keymap.rs
@@ -0,0 +1,153 @@
+//! Key binding tables — single source of truth for both dispatch and
+//! footer help text.
+
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+/// The set of logical actions bound to keys in Normal mode.
+///
+/// Why an enum rather than function pointers in the binding table:
+/// some actions need to read the current model state (e.g., "attach
+/// the selected session" needs `model.sessions[model.selected]`)
+/// and some are stateless (e.g., "select previous"). An enum lets
+/// update.rs match on the action and do whatever state-access each
+/// variant needs. The compiler's exhaustiveness check then enforces
+/// that every variant is handled — no silent drift when we add a
+/// new action.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NormalAction {
+ SelectPrev,
+ SelectNext,
+ AttachSelected,
+ NewSession,
+ KillSelected,
+ Quit,
+}
+
+/// One entry in the Normal-mode binding table.
+pub struct NormalBinding {
+ /// Short key label shown in the footer (e.g. "j", "spc").
+ pub label: &'static str,
+ /// Description shown in the footer (e.g. "down", "attach").
+ pub desc: &'static str,
+ /// Every KeyCode that triggers this action. All entries get the
+ /// same label+desc — useful for binding arrows + vim letters to
+ /// the same action without cluttering the footer.
+ pub keys: &'static [KeyCode],
+ pub action: NormalAction,
+}
+
+/// Normal-mode bindings. Order here is the order shown in the footer.
+///
+/// Case synonyms (`'j'` and `'J'`) are listed explicitly — there's no
+/// automatic case folding at lookup time. Treating Shift-letters as
+/// the same action is then just a matter of whether you enumerate the
+/// uppercase variant here. If a future binding wants distinct uppercase
+/// semantics (Vim-style `G` = bottom), remove the uppercase entry from
+/// the synonym list and add a separate binding for it.
+pub const NORMAL_BINDINGS: &[NormalBinding] = &[
+ NormalBinding {
+ label: "j",
+ desc: "down",
+ keys: &[KeyCode::Char('j'), KeyCode::Char('J'), KeyCode::Down],
+ action: NormalAction::SelectNext,
+ },
+ NormalBinding {
+ label: "k",
+ desc: "up",
+ keys: &[KeyCode::Char('k'), KeyCode::Char('K'), KeyCode::Up],
+ action: NormalAction::SelectPrev,
+ },
+ NormalBinding {
+ label: "spc",
+ desc: "attach",
+ keys: &[KeyCode::Char(' '), KeyCode::Enter],
+ action: NormalAction::AttachSelected,
+ },
+ NormalBinding {
+ label: "n",
+ desc: "new",
+ keys: &[KeyCode::Char('n'), KeyCode::Char('N')],
+ action: NormalAction::NewSession,
+ },
+ NormalBinding {
+ label: "d",
+ desc: "kill",
+ keys: &[KeyCode::Char('d'), KeyCode::Char('D'), KeyCode::Char('x'), KeyCode::Char('X')],
+ action: NormalAction::KillSelected,
+ },
+ NormalBinding {
+ label: "q",
+ desc: "quit",
+ keys: &[KeyCode::Char('q'), KeyCode::Char('Q'), KeyCode::Esc],
+ action: NormalAction::Quit,
+ },
+];
+
+/// Modal-mode footer hints. Display-only: dispatch for these modes
+/// lives inline in update.rs because their accepted input set isn't
+/// a finite list of keys (CreateInput accepts any printable char;
+/// the confirm modes accept `{y, Y}` + `{n, N, Enter, Esc}` with
+/// other keys ignored).
+pub const CREATE_HINTS: &[(&str, &str)] = &[("ret", "create"), ("esc", "cancel")];
+
+/// Shared by ConfirmKill and ConfirmForce. Split into per-mode
+/// constants if the two ever need to diverge.
+pub const CONFIRM_HINTS: &[(&str, &str)] = &[("y/N", "")];
+
+/// Whether this keypress should dispatch to a binding / action.
+///
+/// The whitelist is "modifiers are a subset of `{SHIFT}`". Chord
+/// keys (Ctrl / Alt / Super / Hyper / Meta) are filtered out — they
+/// shouldn't accidentally trigger plain bindings (e.g. Ctrl-D should
+/// not fire `d`'s kill action). Ctrl-C is handled as a special-case
+/// global quit in `update`'s key handler, earlier in the dispatch.
+///
+/// SHIFT is allowed because terminals disagree on where Shift shows
+/// up: some fold it into the KeyCode (Shift-j → `Char('J')`,
+/// modifiers = NONE), while enhanced protocols report it separately
+/// (`Char('J')`, modifiers = SHIFT). Accepting both shapes means
+/// Shift-letter variants fire regardless of terminal.
+pub fn is_dispatchable(key: &KeyEvent) -> bool {
+ (key.modifiers - KeyModifiers::SHIFT).is_empty()
+}
+
+/// Look up which NormalAction (if any) this KeyEvent triggers.
+///
+/// Policy:
+/// - Chord keys are filtered via `is_dispatchable` — Ctrl-D, Alt-J, etc.
+/// return None even when the underlying char is in the table.
+/// - Case folding is explicit in the binding table (see the doc on
+/// `NORMAL_BINDINGS`): nothing is folded at lookup time.
+pub fn normal_action(key: &KeyEvent) -> Option {
+ if !is_dispatchable(key) {
+ return None;
+ }
+ NORMAL_BINDINGS.iter().find(|b| b.keys.contains(&key.code)).map(|b| b.action)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashMap;
+
+ /// Guard against drift: if a new binding accidentally re-uses a
+ /// key already claimed by an existing binding, `normal_action`'s
+ /// linear scan would silently dispatch to whichever comes first
+ /// and the new binding would never fire. Catch that at test time.
+ /// Also catches duplicates within a single binding's `keys` array.
+ #[test]
+ fn no_key_bound_to_multiple_actions() {
+ let mut claimed: HashMap = HashMap::new();
+ for b in NORMAL_BINDINGS {
+ for &k in b.keys {
+ if let Some(existing) = claimed.insert(k, b.label) {
+ panic!(
+ "key {:?} is bound by both [{}] and [{}] — \
+ NORMAL_BINDINGS entries must have disjoint keys",
+ k, existing, b.label,
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/libshpool/src/tui/mod.rs b/libshpool/src/tui/mod.rs
new file mode 100644
index 00000000..ffd4910f
--- /dev/null
+++ b/libshpool/src/tui/mod.rs
@@ -0,0 +1,375 @@
+//! `shpool tui` — interactive session manager.
+//!
+//! High-level architecture (elm-like):
+//!
+//! event loop:
+//! 1. draw the current Model (view.rs)
+//! 2. read the next Event (key, or a follow-up from a Command)
+//! 3. fold Event into Model, possibly yielding a Command (update.rs)
+//! 4. if there's a Command, execute it; its result becomes the next Event
+//!
+//! The split is deliberate: `update` is a pure function and trivially
+//! tested; `view` is pure and snapshot-tested; all side effects
+//! (socket, subprocess, terminal) live in this file's `execute`.
+
+mod attach;
+mod command;
+mod event;
+mod keymap;
+mod model;
+mod store;
+mod suspend;
+mod update;
+mod view;
+
+use std::{
+ env, io,
+ path::PathBuf,
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::{Context, Result};
+use crossterm::{
+ cursor::Hide,
+ event::{self as xterm_event, DisableFocusChange, EnableFocusChange, KeyEventKind},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{backend::CrosstermBackend, Terminal};
+
+use shpool_protocol::Session;
+
+use self::{
+ attach::AttachEnv,
+ command::Command,
+ event::Event,
+ model::{is_attached, Mode, Model},
+ store::SessionStore,
+};
+
+/// `config_file`, `log_file`, and `verbose` are the parent invocation's
+/// top-level flags, forwarded to every `shpool attach` subprocess
+/// spawned from the TUI so those children see the same config /
+/// logging destination / verbosity as the TUI itself.
+pub fn run(
+ socket: PathBuf,
+ config_file: Option,
+ log_file: Option,
+ verbose: u8,
+) -> Result<()> {
+ // Refuse to run inside a shpool session. Nested sessions are
+ // confusing: a force-attach from inside bumps the outer client,
+ // SHPOOL_SESSION_NAME ends up inherited, and ^D leaves the user
+ // at the wrong layer. Print a hint and exit rather than open the
+ // TUI — the user will notice and detach first.
+ if let Ok(name) = env::var("SHPOOL_SESSION_NAME") {
+ eprintln!(
+ "shpool tui: refusing to run inside shpool session '{name}'.\n\
+ Run `shpool detach` first."
+ );
+ return Ok(());
+ }
+
+ // Bring up the terminal. Failure here is fatal — we can't do
+ // anything useful without it — but we make sure to tear it back
+ // down cleanly on any error path so the user's shell doesn't get
+ // left in raw mode.
+ let mut terminal = enter_tui().context("entering TUI")?;
+
+ let attach_env = AttachEnv { socket, config_file, log_file, verbose };
+
+ // The actual loop is in its own function so we can always run
+ // `leave_tui` on exit, even on error.
+ let result = main_loop(&mut terminal, &attach_env);
+
+ // Always tear down. If `result` is already an error we'll
+ // propagate that; teardown errors go to stderr after the terminal
+ // is restored so the user can read them.
+ if let Err(e) = leave_tui(terminal) {
+ eprintln!("shpool tui: error restoring terminal: {e:?}");
+ }
+
+ result
+}
+
+/// Set up the terminal for TUI use. Returns a ratatui `Terminal`
+/// bound to stdout.
+fn enter_tui() -> Result>> {
+ enable_raw_mode().context("enabling raw mode")?;
+ // EnableFocusChange is best-effort: terminals that don't implement
+ // the escape sequence just ignore it, and we never receive
+ // FocusGained events from them — benign no-op.
+ execute!(io::stdout(), EnterAlternateScreen, Hide, EnableFocusChange)
+ .context("entering alt screen")?;
+ let backend = CrosstermBackend::new(io::stdout());
+ let terminal = Terminal::new(backend).context("creating terminal")?;
+ Ok(terminal)
+}
+
+/// Reverse of `enter_tui`. We consume the Terminal because after this
+/// it's invalid to draw to it.
+fn leave_tui(mut terminal: Terminal>) -> Result<()> {
+ // Clear + show cursor first so the user's shell prompt lands in
+ // a sensible place.
+ use crossterm::cursor::Show;
+ execute!(terminal.backend_mut(), DisableFocusChange, LeaveAlternateScreen, Show)
+ .context("leaving alt screen")?;
+ disable_raw_mode().context("disabling raw mode")?;
+ Ok(())
+}
+
+/// The event loop. Runs until the model flags `quit` or we hit an
+/// unrecoverable error.
+fn main_loop(
+ terminal: &mut Terminal>,
+ attach_env: &AttachEnv,
+) -> Result<()> {
+ // SessionStore only needs the socket — it doesn't care about the
+ // forwarded flags. Clone the PathBuf (cheap, one-time) rather than
+ // threading a lifetime through SessionStore.
+ let store = SessionStore::new(attach_env.socket.clone());
+ let mut model = Model::new();
+
+ // Initial fetch: if the daemon is alive, show its session list
+ // immediately; if not, show the error and let the user try
+ // again (quit + retry).
+ //
+ // We feed the result through `update` rather than mutating model
+ // directly so the error-handling path stays in one place.
+ let initial = match store.list() {
+ Ok(sessions) => Event::SessionsRefreshed(sessions),
+ Err(e) => Event::RefreshFailed(format!("{e:#}")),
+ };
+ update::update(&mut model, initial);
+
+ // Surface the first protocol-version-mismatch warning (if any) in
+ // the footer so the user learns about daemon/client drift once.
+ // Subsequent connects may hit the same daemon and re-surface the
+ // warning; we only show it on the first occurrence.
+ if let Some(warning) = store.take_first_warning() {
+ model.set_error(format!("warning: {warning}"));
+ }
+
+ loop {
+ // 1. Render the current state. `now_ms` drives the relative-
+ // time column in the session list ("2m" etc.); passing it
+ // in from here (rather than having view.rs call
+ // SystemTime::now itself) keeps view pure + snapshot-testable.
+ let now = now_ms();
+ terminal.draw(|f| view::view(&model, now, f)).context("drawing frame")?;
+
+ // `quit` is checked AFTER the draw, not before. The final
+ // pass is technically a wasted frame, but LeaveAlternateScreen
+ // wipes it on teardown so the user never sees it. Deliberate —
+ // inverting to check-first would subtly change which frame
+ // the model's visible state matches, and the saved work
+ // (one draw on exit) isn't worth that coupling.
+ if model.quit {
+ return Ok(());
+ }
+
+ // 2. Read the next key. crossterm handles SIGWINCH for us —
+ // a resize shows up as `Event::Resize`, which we treat as a
+ // no-op (the next draw picks up the new size).
+ let next = read_one_event().context("reading input")?;
+
+ // 3. Fold it into the model and maybe get a Command back.
+ let mut next_cmd = update::update(&mut model, next);
+
+ // 4. Execute the Command, possibly producing a follow-up
+ // Event we feed back into update. If update produces ANOTHER
+ // Command from that follow-up, we execute that too, and so
+ // on. This cascade is load-bearing: e.g. create flow is
+ // Key(Enter) -> Create -> AttachExited -> Refresh ->
+ // SessionsRefreshed
+ // where the Refresh step is the one that picks up the newly
+ // created session. If we only executed the first Command
+ // (Create), the new session would never appear.
+ //
+ // Drift note: the specific command/event wiring (e.g.
+ // "AttachExited produces Refresh") lives in
+ // `update::update`'s match arms — this comment is the
+ // narrative but not the source of truth. If that wiring
+ // changes, update this example too.
+ while let Some(cmd) = next_cmd.take() {
+ let follow_up = execute(cmd, &mut model, &store, terminal, attach_env)?;
+ let Some(ev) = follow_up else { break };
+ next_cmd = update::update(&mut model, ev);
+ }
+ }
+}
+
+/// Outcome of an attach pre-flight: what did a fresh `list` say
+/// about the session the user wants to attach to?
+///
+/// Split out from the [`Command::Attach`] executor arm because the
+/// decision is meaningfully separate from the action. Each variant
+/// maps to one response in `execute`.
+///
+/// Why pre-flight at all: the model's view of "is this session
+/// attached elsewhere" can be stale because we only refresh on
+/// keystroke, and even after a refresh the daemon's own state can
+/// lag by ~0.5s (e.g. the user detached from another terminal and
+/// the daemon hasn't reflected it yet). We want that stale state
+/// to flash through without popping a spurious ConfirmForce
+/// prompt, so we re-check with fresh data right before acting.
+enum AttachPreflight {
+ /// `store.list()` itself failed — the error is display-ready.
+ RefreshFailed(anyhow::Error),
+ /// Session no longer exists; another client killed it since
+ /// our last view. The fresh list is included so the caller can
+ /// feed it through [`Event::SessionsRefreshed`] — which routes
+ /// to [`Model::apply_refresh`] via [`update::update`].
+ Gone { sessions: Vec },
+ /// Session exists but is attached from another terminal, and
+ /// force was not set. Caller should pop a ConfirmForce prompt.
+ AttachedElsewhere { sessions: Vec },
+ /// Session exists and is ready to attach. No `sessions` carried —
+ /// the AttachExited handler will cascade into a fresh Refresh.
+ ClearToAttach,
+}
+
+fn preflight_attach(store: &SessionStore, name: &str, force: bool) -> AttachPreflight {
+ let sessions = match store.list() {
+ Ok(s) => s,
+ Err(e) => return AttachPreflight::RefreshFailed(e),
+ };
+ // Two scans over the list: one to check presence (so we can move
+ // `sessions` into Gone without holding a borrow), one to check
+ // attached-state on the matching entry. Scan cost is trivial at
+ // session-list sizes. Structuring as a single-scan `.find()`
+ // creates a borrow that conflicts with moving `sessions` into
+ // the outcome variant.
+ if !sessions.iter().any(|s| s.name == name) {
+ return AttachPreflight::Gone { sessions };
+ }
+ let attached_elsewhere =
+ !force && sessions.iter().find(|s| s.name == name).map(is_attached).unwrap_or(false);
+ if attached_elsewhere {
+ return AttachPreflight::AttachedElsewhere { sessions };
+ }
+ AttachPreflight::ClearToAttach
+}
+
+/// Current wall-clock time in unix milliseconds. Saturates to 0 if
+/// the clock is before the unix epoch (shouldn't happen in practice,
+/// but don't panic over it).
+fn now_ms() -> i64 {
+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
+}
+
+/// Block until crossterm delivers something we want to feed into
+/// update. Resize events are absorbed here — we don't bother the
+/// model with them.
+///
+/// Returns `Event::Key(...)` or `Event::FocusGained`. RefreshFailed /
+/// AttachExited / etc. all come from the executor; having the
+/// read-key path return the same `Event` type as the executor keeps
+/// the main loop simple.
+fn read_one_event() -> Result {
+ loop {
+ match xterm_event::read()? {
+ // KeyEventKind::Press — only real presses, not releases
+ // or repeats. Some terminals send KeyEventKind::Release
+ // on kitty protocol; we'd double-fire without this
+ // filter.
+ xterm_event::Event::Key(k) if k.kind == KeyEventKind::Press => {
+ return Ok(Event::Key(k));
+ }
+ xterm_event::Event::FocusGained => return Ok(Event::FocusGained),
+ // Resize: the next `terminal.draw` will pick up the new
+ // size on its own. Loop around to read another event.
+ xterm_event::Event::Resize(_, _) => continue,
+ // Mouse / paste / focus-lost / key-release: ignore.
+ _ => continue,
+ }
+ }
+}
+
+/// Side-effect executor. Takes a Command from `update`, does the
+/// thing, and returns the follow-up Event (if any) for the main loop
+/// to feed back into update.
+///
+/// All IO (socket, subprocess, terminal suspend) happens here. This
+/// is the layer you look at when debugging "why didn't my kill
+/// work" — all three possibilities (bad protocol call, silent daemon
+/// error, botched UI state refresh) are visible in this function.
+fn execute(
+ cmd: Command,
+ model: &mut Model,
+ store: &SessionStore,
+ terminal: &mut Terminal>,
+ attach_env: &AttachEnv,
+) -> Result