Skip to main content

embers_cli/
lib.rs

1mod automation;
2mod capture;
3mod clients;
4mod formats;
5mod interactive;
6mod options;
7mod popup;
8mod sessions;
9mod target;
10
11use std::ffi::{OsStr, OsString};
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::num::NonZeroU64;
15#[cfg(unix)]
16use std::os::unix::ffi::OsStringExt;
17#[cfg(unix)]
18use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
19#[cfg(windows)]
20use std::os::windows::ffi::OsStringExt;
21use std::path::{Path, PathBuf};
22use std::process::{Command as ProcessCommand, Stdio};
23
24use base64::Engine as _;
25use clap::{Parser, Subcommand};
26use embers_client::{
27    ClientState, CommandContext, CommandRunner, NavigationDirection, PanePlacement, PaneTitles,
28    PresentationModel, SplitResize, drawn_viewport, spawn_pty_size, window_number, window_position,
29};
30use embers_core::{
31    BufferId, FloatingId, MuxError, NodeId, PtySize, Result, SessionId, SplitDirection,
32    new_request_id,
33};
34use embers_protocol::{
35    BufferHistoryPlacement, BufferHistoryScope, BufferLocation, BufferLocationAttachment,
36    BufferLocationResponse, BufferPipeRecord, BufferPipeState, BufferPipeStopReason,
37    BufferRecordState, BufferRequest, BufferResponse, ClientMessage, ClientRecord, ClientRequest,
38    FloatingRecord, FloatingRequest, FloatingResponse, KillServerRequest, NodeBreakDestination,
39    NodeJoinPlacement, NodeRequest, PingRequest, ProtocolClient, ServerResponse, SessionRecord,
40    SessionRequest, SessionSnapshot, SnapshotResponse, SynchronizePanesMode, buffer_ids_in_subtree,
41};
42use embers_server::{BUFFER_ENV_VAR, SOCKET_ENV_VAR, Server, ServerConfig};
43use tokio::time::{Duration, sleep};
44use tracing::warn;
45
46/// What `display-message` shows without a message, like tmux's default.
47const DEFAULT_DISPLAY_MESSAGE: &str = "[#S] #I:#W, current pane #D - (#{pane_current_command})";
48
49/// What `new-window -P` and `split-window -P` print without `-F`, like tmux.
50const DEFAULT_PANE_FORMAT: &str = "#{session_name}:#{window_index}.#{pane_index}";
51
52/// The client a `run_shell` child runs for, like tmux's client of a
53/// `run-shell` command; read with [`SOCKET_ENV_VAR`] like [`BUFFER_ENV_VAR`].
54const CLIENT_ENV_VAR: &str = "EMBERS_CLIENT";
55
56/// Environment variable selecting the tracing filter (e.g. `info`, `embers=debug`).
57pub const EMBERS_LOG_ENV_VAR: &str = "EMBERS_LOG";
58
59#[derive(Debug, Parser)]
60#[command(name = "embers", about = "headless terminal multiplexer for embers")]
61pub struct Cli {
62    #[arg(long, global = true)]
63    pub socket: Option<PathBuf>,
64    #[arg(long, global = true)]
65    pub config: Option<PathBuf>,
66    #[arg(
67        long,
68        visible_alias = "log-level",
69        global = true,
70        value_name = "FILTER"
71    )]
72    pub log: Option<String>,
73    /// `-v`/`-vv`: log at debug/trace. The short form goes before the command,
74    /// which leaves `-v` to commands such as `split-window`.
75    #[arg(short = 'v', action = clap::ArgAction::Count)]
76    pub verbose_short: u8,
77    #[arg(long = "verbose", global = true, action = clap::ArgAction::Count, hide = true)]
78    pub verbose: u8,
79    /// Number windows from this index, like tmux `base-index`. Defaults to
80    /// `EMBERS_BASE_INDEX`; when neither is set, window numbers start at 0 and
81    /// numeric targets are accepted as 0- or 1-based unless that is ambiguous.
82    #[arg(long = "base-index", global = true, value_name = "N", value_parser = parse_base_index_arg)]
83    pub base_index: Option<u32>,
84    #[command(subcommand)]
85    pub command: Option<Command>,
86}
87
88impl Cli {
89    pub fn log_filter(&self) -> String {
90        if let Some(filter) = self.log.as_ref().filter(|value| !value.trim().is_empty()) {
91            return filter.clone();
92        }
93        match self.verbose.saturating_add(self.verbose_short) {
94            0 => {}
95            1 => return "debug".to_owned(),
96            _ => return "trace".to_owned(),
97        }
98        if let Some(filter) = std::env::var(EMBERS_LOG_ENV_VAR)
99            .ok()
100            .filter(|value| !value.trim().is_empty())
101        {
102            return filter;
103        }
104        if let Some(filter) = std::env::var("RUST_LOG")
105            .ok()
106            .filter(|value| !value.trim().is_empty())
107        {
108            return filter;
109        }
110        "info".to_owned()
111    }
112
113    /// The window base index from `--base-index`, then `EMBERS_BASE_INDEX`.
114    pub fn base_index(&self) -> Option<u32> {
115        self.base_index.or_else(embers_client::base_index_from_env)
116    }
117}
118
119fn parse_base_index_arg(value: &str) -> std::result::Result<u32, String> {
120    embers_client::parse_base_index(value).ok_or_else(|| {
121        format!(
122            "expected a number from 0 to {}",
123            embers_client::numbering::MAX_BASE_INDEX
124        )
125    })
126}
127
128#[derive(Debug, Subcommand)]
129pub enum Command {
130    Attach {
131        #[arg(short = 't', long = "target")]
132        target: Option<String>,
133    },
134    #[command(name = "__serve", hide = true)]
135    Serve,
136    #[command(name = "__runtime-keeper", hide = true)]
137    RuntimeKeeper {
138        #[arg(long = "keeper-socket")]
139        keeper_socket: PathBuf,
140        #[arg(long)]
141        cols: u16,
142        #[arg(long)]
143        rows: u16,
144        #[arg(long = "pixel-width", default_value_t = 0)]
145        pixel_width: u16,
146        #[arg(long = "pixel-height", default_value_t = 0)]
147        pixel_height: u16,
148        #[arg(long)]
149        cwd: Option<PathBuf>,
150        #[arg(long = "env", value_parser = parse_env_arg)]
151        env: Vec<(String, OsString)>,
152        #[arg(last = true)]
153        command: Vec<String>,
154    },
155    Automation {
156        #[arg(short = 't', long = "target", conflicts_with = "all_sessions")]
157        target: Option<String>,
158        #[arg(long, conflicts_with = "target")]
159        all_sessions: bool,
160    },
161    Ping {
162        #[arg(default_value = "phase0")]
163        payload: String,
164    },
165    /// Stop the server and end every program it runs. Unlike stopping the
166    /// server with a signal, nothing is left for a restarted server to
167    /// reattach to or restore.
168    #[command(name = "kill-server")]
169    KillServer,
170    /// Create a session, like tmux `new-session`: with a first window running
171    /// COMMAND (or `$SHELL`) in `-c DIR`, attached unless `-d`. The old form,
172    /// `new-session NAME`, creates an empty session and prints its id and name.
173    #[command(name = "new-session", visible_alias = "new")]
174    NewSession {
175        /// The old form's session name.
176        #[arg(
177            value_name = "NAME",
178            conflicts_with_all = ["session_name", "attach_existing", "cwd", "window_name", "width", "height", "print", "format", "command"]
179        )]
180        name: Option<String>,
181        /// Do not attach (or, from the command prompt, switch) to the session.
182        #[arg(short = 'd')]
183        detached: bool,
184        /// Attach to session `-s` if it exists instead of failing.
185        #[arg(short = 'A')]
186        attach_existing: bool,
187        #[arg(short = 's', value_name = "NAME")]
188        session_name: Option<String>,
189        /// Name of the first window.
190        #[arg(short = 'n', value_name = "WINDOW")]
191        window_name: Option<String>,
192        /// Working directory of the first window; may use formats such as
193        /// `#{pane_current_path}`. Defaults to the current directory.
194        #[arg(short = 'c', value_name = "DIR")]
195        cwd: Option<String>,
196        /// Size to lay the session out at until a client shows it.
197        #[arg(short = 'x', value_name = "COLUMNS")]
198        width: Option<u16>,
199        #[arg(short = 'y', value_name = "ROWS")]
200        height: Option<u16>,
201        /// Print information about the session (`-F`, default `#{session_name}:`).
202        #[arg(short = 'P')]
203        print: bool,
204        #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
205        format: Option<String>,
206        #[arg(last = true)]
207        command: Vec<String>,
208    },
209    #[command(name = "list-sessions", visible_alias = "ls")]
210    ListSessions {
211        /// Print each session with this format instead of `id<TAB>name`.
212        #[arg(short = 'F', value_name = "FORMAT")]
213        format: Option<String>,
214    },
215    #[command(name = "has-session")]
216    HasSession {
217        #[arg(short = 't', long = "target")]
218        target: String,
219    },
220    #[command(name = "kill-session")]
221    /// Close a session and end the programs in its panes and popups.
222    KillSession {
223        #[arg(short = 't', long = "target")]
224        target: Option<String>,
225        #[arg(long)]
226        force: bool,
227        /// Keep the programs running as detached buffers instead.
228        #[arg(long)]
229        detach: bool,
230    },
231    #[command(name = "rename-session")]
232    RenameSession {
233        #[arg(short = 't', long = "target")]
234        target: Option<String>,
235        name: String,
236    },
237    #[command(name = "list-buffers")]
238    ListBuffers {
239        #[arg(short = 't', long = "target")]
240        target: Option<String>,
241        #[arg(long, conflicts_with = "detached")]
242        attached: bool,
243        #[arg(long, conflicts_with = "attached")]
244        detached: bool,
245    },
246    #[command(name = "attach-buffer")]
247    AttachBuffer {
248        buffer_id: u64,
249        #[arg(short = 't', long = "target")]
250        target: Option<String>,
251    },
252    #[command(name = "list-clients", visible_alias = "lsc")]
253    ListClients {
254        /// Only the clients showing this session.
255        #[arg(short = 't', long = "target")]
256        target: Option<String>,
257        /// Print each client with this format instead of `id<TAB>session<TAB>scope`.
258        /// Besides the variables of the client's session and focused pane, it
259        /// takes `#{client_name}` and `#{client_id}` (the client's id) and
260        /// `#{client_session}` (the name of the session it shows).
261        #[arg(short = 'F', value_name = "FORMAT")]
262        format: Option<String>,
263    },
264    /// Detach a client: `-c`, else the client the command runs for (see
265    /// `switch-client`).
266    #[command(name = "detach-client", visible_alias = "detach")]
267    DetachClient {
268        #[arg(conflicts_with = "client")]
269        client_id: Option<u64>,
270        #[arg(short = 'c', long = "client", value_name = "CLIENT")]
271        client: Option<u64>,
272    },
273    /// Show session `-t` on a client, like tmux. A window or pane target
274    /// (`%3`, `work:2`, `work:2.1`) also makes that window current and focuses
275    /// the pane. Without `-c` the client is the command prompt's, else
276    /// `EMBERS_CLIENT`, else the one showing the session of the pane the
277    /// command runs in, else the only attached one. `-n`/`-p` move to the
278    /// next/previous session by name.
279    #[command(name = "switch-client", visible_alias = "switchc")]
280    SwitchClient {
281        #[arg(conflicts_with = "client")]
282        client_id: Option<u64>,
283        #[arg(short = 'c', long = "client", value_name = "CLIENT")]
284        client: Option<u64>,
285        #[arg(
286            short = 't',
287            long = "target",
288            required_unless_present_any = ["next", "previous"],
289            conflicts_with_all = ["next", "previous"]
290        )]
291        target: Option<String>,
292        #[arg(short = 'n', conflicts_with = "previous")]
293        next: bool,
294        #[arg(short = 'p')]
295        previous: bool,
296    },
297    Buffer {
298        #[command(subcommand)]
299        command: BufferCommand,
300    },
301    Node {
302        #[command(subcommand)]
303        command: NodeCommand,
304    },
305    /// Add a window to session `-t` running COMMAND (or `$SHELL`) in `-c DIR`
306    /// (default: the current directory). Prints its number and title, or
307    /// with `-P` the format `-F` (default
308    /// `#{session_name}:#{window_index}.#{pane_index}`).
309    #[command(name = "new-window", visible_alias = "neww")]
310    NewWindow {
311        #[arg(short = 't', long = "target")]
312        target: Option<String>,
313        #[arg(short = 'n', long)]
314        title: Option<String>,
315        #[arg(short = 'c', value_name = "DIR")]
316        cwd: Option<String>,
317        /// Keep the current window active.
318        #[arg(short = 'd')]
319        detached: bool,
320        #[arg(short = 'P')]
321        print: bool,
322        #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
323        format: Option<String>,
324        #[arg(last = true)]
325        command: Vec<String>,
326    },
327    #[command(name = "list-windows", visible_alias = "lsw")]
328    ListWindows {
329        #[arg(short = 't', long = "target", conflicts_with = "all")]
330        target: Option<String>,
331        /// Every session's windows.
332        #[arg(short = 'a')]
333        all: bool,
334        /// Print each window with this format instead of
335        /// `number<TAB>active<TAB>title`.
336        #[arg(short = 'F', value_name = "FORMAT")]
337        format: Option<String>,
338    },
339    #[command(name = "select-window")]
340    SelectWindow {
341        #[arg(short = 't', long = "target")]
342        target: String,
343    },
344    #[command(name = "rename-window")]
345    RenameWindow {
346        #[arg(short = 't', long = "target")]
347        target: Option<String>,
348        title: String,
349    },
350    #[command(name = "kill-window")]
351    /// Close a window and end the programs in its panes.
352    KillWindow {
353        #[arg(short = 't', long = "target")]
354        target: Option<String>,
355        /// Keep the programs running as detached buffers instead.
356        #[arg(long)]
357        detach: bool,
358    },
359    /// Split pane `-t` and run COMMAND (or `$SHELL`) in the new pane, in
360    /// `-c DIR` (default: the current directory). `-h` puts the new pane to
361    /// the right and `-v` below, like tmux; without either it goes to the
362    /// right. `--horizontal` (a horizontal divider) is `-v`, and `--vertical`
363    /// is `-h`. Prints the new pane's id, or with `-P` the
364    /// format `-F` (default `#{session_name}:#{window_index}.#{pane_index}`).
365    #[command(
366        name = "split-window",
367        visible_alias = "splitw",
368        disable_help_flag = true
369    )]
370    SplitWindow {
371        #[arg(short = 't', long = "target")]
372        target: Option<String>,
373        #[arg(short = 'h', group = "split_direction")]
374        side_by_side: bool,
375        #[arg(short = 'v', group = "split_direction")]
376        stacked: bool,
377        #[arg(long, group = "split_direction")]
378        horizontal: bool,
379        #[arg(long, group = "split_direction")]
380        vertical: bool,
381        #[arg(short = 'c', value_name = "DIR")]
382        cwd: Option<String>,
383        /// Keep the split pane focused.
384        #[arg(short = 'd')]
385        detached: bool,
386        #[arg(short = 'P')]
387        print: bool,
388        #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
389        format: Option<String>,
390        #[arg(long, action = clap::ArgAction::Help)]
391        help: Option<bool>,
392        #[arg(last = true)]
393        command: Vec<String>,
394    },
395    #[command(name = "list-panes", visible_alias = "lsp")]
396    ListPanes {
397        #[arg(short = 't', long = "target", conflicts_with = "all")]
398        target: Option<String>,
399        /// Every pane of the target's session, not just its window's.
400        #[arg(short = 's', conflicts_with = "all")]
401        session: bool,
402        /// Every pane of every session.
403        #[arg(short = 'a')]
404        all: bool,
405        /// Print each pane with this format instead of
406        /// `pane<TAB>buffer<TAB>focused<TAB>title`.
407        #[arg(short = 'F', value_name = "FORMAT")]
408        format: Option<String>,
409    },
410    /// Focus pane `-t` (default: the pane the command runs in, else the
411    /// focused one); with `-L/-R/-U/-D` the pane next to it on that side, as
412    /// the client's focus actions find it; with `-l` the pane focused before
413    /// the current one in the session's current window (tmux `select-pane -l`).
414    #[command(name = "select-pane", visible_alias = "selectp")]
415    SelectPane {
416        #[arg(short = 't', long = "target")]
417        target: Option<String>,
418        #[arg(short = 'l', long = "last", conflicts_with_all = ["target", "pane_direction"])]
419        last: bool,
420        #[arg(short = 'L', group = "pane_direction")]
421        left: bool,
422        #[arg(short = 'R', group = "pane_direction")]
423        right: bool,
424        #[arg(short = 'U', group = "pane_direction")]
425        up: bool,
426        #[arg(short = 'D', group = "pane_direction")]
427        down: bool,
428    },
429    /// Set pane `-t`'s option NAME to VALUE, like tmux `set-option -p`. Options
430    /// are the pane's buffer options (`buffer set-option`): `@name` is the
431    /// option `name`, and `remain-on-exit` is the only other one. `-u` unsets
432    /// it, `-o` fails if it is set, `-q` ignores an unknown option. Windows,
433    /// sessions and the server have no options, so `-p` is required.
434    #[command(name = "set-option", visible_alias = "set")]
435    SetOption {
436        #[arg(short = 'p')]
437        pane: bool,
438        #[arg(short = 'w', hide = true)]
439        window: bool,
440        #[arg(short = 's', hide = true)]
441        server: bool,
442        #[arg(short = 'g', hide = true)]
443        global: bool,
444        #[arg(short = 't', long = "target")]
445        target: Option<String>,
446        #[arg(short = 'u')]
447        unset: bool,
448        #[arg(short = 'o')]
449        only_if_unset: bool,
450        #[arg(short = 'q')]
451        quiet: bool,
452        name: String,
453        #[arg(conflicts_with = "unset")]
454        value: Option<String>,
455    },
456    /// Print pane `-t`'s options, or option NAME, like tmux `show-options -p`:
457    /// `NAME VALUE` lines, or with `-v` the values alone. `-q` prints nothing
458    /// for an option that is not set.
459    #[command(name = "show-options", visible_alias = "show")]
460    ShowOptions {
461        #[arg(short = 'p')]
462        pane: bool,
463        #[arg(short = 'w', hide = true)]
464        window: bool,
465        #[arg(short = 's', hide = true)]
466        server: bool,
467        #[arg(short = 'g', hide = true)]
468        global: bool,
469        #[arg(short = 't', long = "target")]
470        target: Option<String>,
471        #[arg(short = 'v')]
472        value_only: bool,
473        #[arg(short = 'q')]
474        quiet: bool,
475        name: Option<String>,
476    },
477    /// Resize a pane like tmux: `-L/-R/-U/-D [N]` moves its border N cells
478    /// (default 1), `-Z` toggles zoom, and `--sizes` sets the parent split's
479    /// weights directly.
480    #[command(name = "resize-pane")]
481    ResizePane {
482        #[arg(short = 't', long = "target")]
483        target: Option<String>,
484        #[arg(long, value_delimiter = ',', conflicts_with_all = ["direction", "zoom"])]
485        sizes: Vec<u16>,
486        #[arg(short = 'L', group = "direction")]
487        left: bool,
488        #[arg(short = 'R', group = "direction")]
489        right: bool,
490        #[arg(short = 'U', group = "direction")]
491        up: bool,
492        #[arg(short = 'D', group = "direction")]
493        down: bool,
494        #[arg(short = 'Z', conflicts_with = "direction")]
495        zoom: bool,
496        #[arg(requires = "direction")]
497        adjustment: Option<u16>,
498    },
499    /// Turn tmux-style synchronize-panes on or off for a window, or toggle it
500    /// when no state is given. Prints the resulting state.
501    #[command(name = "synchronize-panes")]
502    SynchronizePanes {
503        #[arg(short = 't', long = "target")]
504        target: Option<String>,
505        state: Option<SwitchArg>,
506    },
507    #[command(name = "send-keys")]
508    SendKeys {
509        #[arg(short = 't', long = "target")]
510        target: Option<String>,
511        #[arg(long)]
512        enter: bool,
513        keys: Vec<String>,
514    },
515    /// Print a pane's text. With any of `-p`, `-S`, `-E`, `-J` or `-e` it works
516    /// like tmux `capture-pane -p`: the screen, or lines `-S` to `-E` (0 is the
517    /// screen's first row, negative numbers are history, `-` is the start of
518    /// the history or the end of the screen), `-J` joining wrapped lines and
519    /// `-e` keeping colours and attributes as escape sequences. Without any,
520    /// it prints the whole history and screen as before.
521    #[command(name = "capture-pane", visible_alias = "capturep")]
522    CapturePane {
523        #[arg(short = 't', long = "target")]
524        target: Option<String>,
525        #[arg(short = 'p')]
526        print: bool,
527        #[arg(short = 'S', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
528        start: Option<capture::LineBound>,
529        #[arg(short = 'E', value_name = "LINE", allow_hyphen_values = true, value_parser = capture::parse_line_bound)]
530        end: Option<capture::LineBound>,
531        #[arg(short = 'J')]
532        join: bool,
533        #[arg(short = 'e')]
534        escapes: bool,
535    },
536    #[command(name = "kill-pane")]
537    /// Close a pane and end its program.
538    KillPane {
539        #[arg(short = 't', long = "target")]
540        target: Option<String>,
541        /// Keep the program running as a detached buffer instead.
542        #[arg(long)]
543        detach: bool,
544    },
545    /// Show COMMAND (or `$SHELL`) in a popup over pane `-t`'s session and,
546    /// like tmux run from a shell, wait until the popup closes, exiting with
547    /// the command's status. `--no-wait` returns at once and prints the popup
548    /// id. Sizes and positions are cells or a percentage of the client's view
549    /// (`90%`); positions also take `C` (centre) and, for `-x`, `R` (right).
550    /// The popup defaults to half the view, centred.
551    #[command(
552        name = "display-popup",
553        visible_alias = "popup",
554        disable_help_flag = true
555    )]
556    DisplayPopup {
557        #[arg(short = 't', long = "target")]
558        target: Option<String>,
559        /// Title, which may use formats.
560        #[arg(short = 'T', long)]
561        title: Option<String>,
562        #[arg(short = 'x', long = "x", value_parser = popup::parse_popup_position)]
563        x: Option<popup::PopupPosition>,
564        #[arg(short = 'y', long = "y", value_parser = popup::parse_popup_position)]
565        y: Option<popup::PopupPosition>,
566        #[arg(short = 'w', long = "width", value_parser = popup::parse_popup_length)]
567        width: Option<popup::PopupLength>,
568        #[arg(short = 'h', long = "height", value_parser = popup::parse_popup_length)]
569        height: Option<popup::PopupLength>,
570        /// Close when the command exits, as popups always do (`-E`); given
571        /// twice (`-EE`), only when it exits with status 0.
572        #[arg(short = 'E', action = clap::ArgAction::Count)]
573        close_on_exit: u8,
574        /// Working directory (tmux's `-d`; `-c` is accepted too); may use
575        /// formats such as `#{pane_current_path}`. Defaults to the current
576        /// directory.
577        #[arg(short = 'd', short_alias = 'c', long = "cwd", value_name = "DIR")]
578        cwd: Option<String>,
579        /// Print information about the popup once it is shown (`-F`, default
580        /// `#{popup_id}`).
581        #[arg(short = 'P')]
582        print: bool,
583        #[arg(short = 'F', value_name = "FORMAT", requires = "print")]
584        format: Option<String>,
585        /// Return once the popup is shown and print its id.
586        #[arg(long)]
587        no_wait: bool,
588        /// Draw no frame or title: the command fills the whole popup.
589        #[arg(short = 'B', long = "no-border")]
590        no_border: bool,
591        #[arg(long, action = clap::ArgAction::Help)]
592        help: Option<bool>,
593        #[arg(last = true)]
594        command: Vec<String>,
595    },
596    #[command(name = "kill-popup")]
597    /// Close a popup and end the programs in it.
598    KillPopup {
599        #[arg(short = 't', long = "target")]
600        target: Option<String>,
601        /// Keep the programs running as detached buffers instead.
602        #[arg(long)]
603        detach: bool,
604    },
605    /// Show a message in the status line of the clients showing the target
606    /// pane's session (or of client `-c`) for a few seconds, like tmux. `-p`
607    /// prints it instead. The message may use `#S`, `#I`, `#W`, `#D`, `#T`,
608    /// `#{pane_current_path}` and `#{pane_current_command}`.
609    #[command(name = "display-message", visible_alias = "display")]
610    DisplayMessage {
611        #[arg(short = 't', long = "target")]
612        target: Option<String>,
613        #[arg(short = 'c', long = "client")]
614        client: Option<NonZeroU64>,
615        #[arg(short = 'p', long = "print")]
616        print: bool,
617        /// How long to show the message, in milliseconds.
618        #[arg(short = 'd', long = "duration", value_name = "MS")]
619        duration: Option<u32>,
620        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
621        message: Vec<String>,
622    },
623    /// Set an environment variable for panes created later in a session, or in
624    /// every session with `-g`, like tmux. `-u` unsets it.
625    #[command(name = "set-environment", visible_alias = "setenv")]
626    SetEnvironment {
627        #[arg(short = 't', long = "target", conflicts_with = "global")]
628        target: Option<String>,
629        #[arg(short = 'g', long = "global")]
630        global: bool,
631        #[arg(short = 'u', long = "unset")]
632        unset: bool,
633        name: String,
634        #[arg(conflicts_with = "unset", allow_hyphen_values = true)]
635        value: Option<String>,
636    },
637    /// Print a session's environment (or the global one with `-g`) as
638    /// `NAME=VALUE` lines, or one variable when NAME is given.
639    #[command(name = "show-environment", visible_alias = "showenv")]
640    ShowEnvironment {
641        #[arg(short = 't', long = "target", conflicts_with = "global")]
642        target: Option<String>,
643        #[arg(short = 'g', long = "global")]
644        global: bool,
645        name: Option<String>,
646    },
647}
648
649#[derive(Debug, Subcommand)]
650pub enum BufferCommand {
651    Show {
652        buffer_id: u64,
653    },
654    Pipe {
655        #[command(subcommand)]
656        command: BufferPipeCommand,
657    },
658    Reveal {
659        buffer_id: u64,
660        #[arg(long)]
661        client: Option<NonZeroU64>,
662    },
663    History {
664        buffer_id: u64,
665        #[arg(long, default_value = "full")]
666        scope: HistoryScopeArg,
667        #[arg(long, default_value = "tab")]
668        placement: HistoryPlacementArg,
669        #[arg(long)]
670        client: Option<NonZeroU64>,
671    },
672    SetOption {
673        #[arg(short = 't', long = "target")]
674        target: Option<String>,
675        key: String,
676        value: Option<String>,
677        #[arg(long, conflicts_with = "value")]
678        unset: bool,
679    },
680    ShowOptions {
681        #[arg(short = 't', long = "target")]
682        target: Option<String>,
683    },
684}
685
686#[derive(Debug, Subcommand)]
687pub enum BufferPipeCommand {
688    Start {
689        buffer_id: u64,
690        #[arg(long)]
691        cwd: Option<String>,
692        #[arg(long = "env", value_parser = parse_string_env_arg)]
693        env: Vec<(String, String)>,
694        #[arg(last = true)]
695        command: Vec<String>,
696    },
697    Stop {
698        buffer_id: u64,
699    },
700    Show {
701        buffer_id: u64,
702    },
703}
704
705#[derive(Debug, Subcommand)]
706pub enum NodeCommand {
707    Zoom {
708        node_id: u64,
709    },
710    Unzoom {
711        #[arg(short = 't', long = "target")]
712        target: Option<String>,
713    },
714    ToggleZoom {
715        node_id: u64,
716    },
717    Swap {
718        first_node_id: u64,
719        second_node_id: u64,
720    },
721    Break {
722        node_id: u64,
723        #[arg(long = "to")]
724        destination: BreakDestinationArg,
725    },
726    JoinBuffer {
727        node_id: u64,
728        buffer_id: u64,
729        #[arg(long = "as", default_value = "tab-after")]
730        placement: JoinPlacementArg,
731    },
732    MoveBefore {
733        node_id: u64,
734        sibling_id: u64,
735    },
736    MoveAfter {
737        node_id: u64,
738        sibling_id: u64,
739    },
740}
741
742#[derive(Clone, Copy, Debug, clap::ValueEnum)]
743pub enum SwitchArg {
744    On,
745    Off,
746}
747
748#[derive(Clone, Copy, Debug, clap::ValueEnum)]
749pub enum HistoryScopeArg {
750    Full,
751    Visible,
752}
753
754#[derive(Clone, Copy, Debug, clap::ValueEnum)]
755pub enum HistoryPlacementArg {
756    Tab,
757    Floating,
758}
759
760#[derive(Clone, Copy, Debug, clap::ValueEnum)]
761pub enum BreakDestinationArg {
762    Tab,
763    Floating,
764}
765
766#[derive(Clone, Copy, Debug, clap::ValueEnum)]
767pub enum JoinPlacementArg {
768    Left,
769    Right,
770    Up,
771    Down,
772    #[value(name = "tab-before")]
773    TabBefore,
774    #[value(name = "tab-after")]
775    TabAfter,
776}
777
778/// What a command run from a shell leaves to do once it has run.
779struct Outcome {
780    output: String,
781    /// Attach this terminal to the session with this name (`new-session`).
782    attach_session: Option<String>,
783    /// Exit with this status (`display-popup`).
784    exit_status: Option<i32>,
785}
786
787async fn execute(socket: &Path, command: Command, base_index: Option<u32>) -> Result<Outcome> {
788    use std::io::IsTerminal as _;
789
790    let mut connection = CliConnection::connect(socket).await?;
791    connection.base_index = base_index;
792    let pane_socket = std::env::var_os(SOCKET_ENV_VAR);
793    connection.pane_buffer = pane_buffer(
794        std::env::var(BUFFER_ENV_VAR).ok().as_deref(),
795        pane_socket.as_deref(),
796        socket,
797    );
798    connection.caller_client = pane_buffer(
799        std::env::var(CLIENT_ENV_VAR).ok().as_deref(),
800        pane_socket.as_deref(),
801        socket,
802    )
803    .and_then(|client| NonZeroU64::new(client.0));
804    connection.working_dir = std::env::current_dir()
805        .ok()
806        .map(|dir| dir.to_string_lossy().into_owned());
807    connection.wait_for_popups = true;
808    connection.can_attach = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
809    let output = execute_command(&mut connection, command).await?;
810    Ok(Outcome {
811        output,
812        attach_session: connection.attach_session,
813        exit_status: connection.exit_status,
814    })
815}
816
817async fn execute_command(connection: &mut CliConnection, command: Command) -> Result<String> {
818    match command {
819        Command::Attach { .. }
820        | Command::Serve
821        | Command::RuntimeKeeper { .. }
822        | Command::Automation { .. } => Err(MuxError::invalid_input(
823            "interactive commands must be dispatched through run()",
824        )),
825        Command::Ping { payload } => {
826            let response = connection
827                .request(ClientMessage::Ping(PingRequest {
828                    request_id: new_request_id(),
829                    payload,
830                }))
831                .await?;
832            match response {
833                ServerResponse::Pong(response) => Ok(format!("pong {}", response.payload)),
834                other => Err(MuxError::protocol(format!(
835                    "unexpected response to ping request: {other:?}"
836                ))),
837            }
838        }
839        Command::KillServer => {
840            connection
841                .request(ClientMessage::KillServer(KillServerRequest {
842                    request_id: new_request_id(),
843                }))
844                .await?;
845            Ok(String::new())
846        }
847        // The old form never attaches, so `-d` changes nothing.
848        Command::NewSession {
849            name: Some(name), ..
850        } => {
851            let response = connection
852                .request(ClientMessage::Session(SessionRequest::Create {
853                    request_id: new_request_id(),
854                    name,
855                }))
856                .await?;
857            let snapshot = expect_session_snapshot(response, "new-session")?;
858            Ok(format!(
859                "{}\t{}",
860                snapshot.session.id, snapshot.session.name
861            ))
862        }
863        Command::NewSession {
864            name: None,
865            detached,
866            attach_existing,
867            session_name,
868            window_name,
869            cwd,
870            width,
871            height,
872            print,
873            format,
874            command,
875        } => {
876            connection
877                .new_session(sessions::NewSession {
878                    detached,
879                    attach_existing,
880                    name: session_name,
881                    window_name,
882                    cwd,
883                    width,
884                    height,
885                    format: print.then(|| format.unwrap_or_else(|| "#{session_name}:".to_owned())),
886                    command,
887                })
888                .await
889        }
890        Command::ListSessions { format } => {
891            let sessions = connection.list_sessions().await?;
892            let Some(format) = format else {
893                return Ok(format_sessions(&sessions));
894            };
895            let mut lines = Vec::new();
896            for session in sessions {
897                let snapshot = connection.session_snapshot(session.id).await?;
898                lines.push(connection.expand_for_pane(&snapshot, None, &format).await?);
899            }
900            Ok(lines.join("\n"))
901        }
902        Command::HasSession { target } => {
903            connection.resolve_session_record(Some(&target)).await?;
904            Ok(String::new())
905        }
906        Command::KillSession {
907            target,
908            force,
909            detach,
910        } => {
911            let snapshot = connection
912                .resolve_session_snapshot(target.as_deref())
913                .await?;
914            let shown = snapshot
915                .buffers
916                .iter()
917                .map(|buffer| buffer.id)
918                .collect::<Vec<_>>();
919            connection
920                .request(ClientMessage::Session(SessionRequest::Close {
921                    request_id: new_request_id(),
922                    session_id: snapshot.session.id,
923                    force,
924                }))
925                .await?;
926            if !detach {
927                connection.kill_detached_buffers(shown).await?;
928            }
929            Ok(String::new())
930        }
931        Command::RenameSession { target, name } => {
932            let session = connection.resolve_session_record(target.as_deref()).await?;
933            connection
934                .request(ClientMessage::Session(SessionRequest::Rename {
935                    request_id: new_request_id(),
936                    session_id: session.id,
937                    name,
938                }))
939                .await?;
940            Ok(String::new())
941        }
942        Command::ListBuffers {
943            target,
944            attached,
945            detached,
946        } => {
947            let session_id = match target {
948                Some(target) => Some(connection.resolve_session_record(Some(&target)).await?.id),
949                None => None,
950            };
951            let response = connection
952                .request(ClientMessage::Buffer(BufferRequest::List {
953                    request_id: new_request_id(),
954                    session_id,
955                    attached_only: attached,
956                    detached_only: detached,
957                }))
958                .await?;
959            match response {
960                ServerResponse::Buffers(response) => Ok(format_buffers(&response.buffers)),
961                other => Err(MuxError::protocol(format!(
962                    "unexpected response to list-buffers: {other:?}"
963                ))),
964            }
965        }
966        Command::AttachBuffer { buffer_id, target } => {
967            let pane = connection.resolve_pane(target.as_deref()).await?;
968            let response = connection
969                .request(ClientMessage::Node(NodeRequest::MoveBufferToNode {
970                    request_id: new_request_id(),
971                    buffer_id: BufferId(buffer_id),
972                    target_leaf_node_id: pane.leaf_id,
973                }))
974                .await?;
975            expect_session_snapshot(response, "attach-buffer")?;
976            Ok(String::new())
977        }
978        Command::ListClients { target, format } => {
979            let sessions = connection.list_sessions().await?;
980            let mut clients = connection.list_clients().await?;
981            if let Some(target) = target {
982                let session = connection.resolve_session_record(Some(&target)).await?;
983                clients.retain(|client| client.current_session_id == Some(session.id));
984            }
985            let Some(format) = format else {
986                return Ok(format_clients(&clients, &sessions));
987            };
988            let mut lines = Vec::new();
989            for client in clients {
990                let session_name = client
991                    .current_session_id
992                    .and_then(|id| sessions.iter().find(|session| session.id == id))
993                    .map(|session| session.name.clone())
994                    .unwrap_or_default();
995                let extra = std::collections::BTreeMap::from([
996                    ("client_name".to_owned(), client.id.to_string()),
997                    ("client_id".to_owned(), client.id.to_string()),
998                    ("client_session".to_owned(), session_name),
999                ]);
1000                let line = match client.current_session_id {
1001                    Some(session_id) => {
1002                        let snapshot = connection.session_snapshot(session_id).await?;
1003                        connection
1004                            .expand_for_pane_with(&snapshot, None, &format, extra)
1005                            .await?
1006                    }
1007                    None => embers_client::expand_format(
1008                        &format,
1009                        &embers_client::FormatContext {
1010                            extra,
1011                            ..embers_client::FormatContext::default()
1012                        },
1013                    ),
1014                };
1015                lines.push(line);
1016            }
1017            Ok(lines.join("\n"))
1018        }
1019        Command::DetachClient { client_id, client } => {
1020            let client_id = connection.target_client(client_id.or(client)).await?;
1021            connection
1022                .request(ClientMessage::Client(ClientRequest::Detach {
1023                    request_id: new_request_id(),
1024                    client_id: Some(client_id),
1025                }))
1026                .await?;
1027            Ok(String::new())
1028        }
1029        Command::SwitchClient {
1030            client_id,
1031            client,
1032            target,
1033            next: _,
1034            previous,
1035        } => {
1036            let client_id = connection.target_client(client_id.or(client)).await?;
1037            let (session_id, node_id) = match target {
1038                Some(target) => connection.switch_target(&target).await?,
1039                None => (
1040                    connection.neighbour_session(client_id, !previous).await?.id,
1041                    None,
1042                ),
1043            };
1044            match connection
1045                .request(ClientMessage::Client(ClientRequest::Switch {
1046                    request_id: new_request_id(),
1047                    client_id: Some(client_id),
1048                    session_id,
1049                    node_id,
1050                }))
1051                .await?
1052            {
1053                ServerResponse::Client(_) => Ok(String::new()),
1054                other => Err(MuxError::protocol(format!(
1055                    "unexpected response to switch-client: {other:?}"
1056                ))),
1057            }
1058        }
1059        Command::Buffer { command } => match command {
1060            BufferCommand::Show { buffer_id } => {
1061                let requested_buffer_id = BufferId(buffer_id);
1062                let response = connection
1063                    .request(ClientMessage::Buffer(BufferRequest::Inspect {
1064                        request_id: new_request_id(),
1065                        buffer_id: requested_buffer_id,
1066                    }))
1067                    .await?;
1068                let (buffer, location, _) = expect_buffer_with_location(response, "buffer show")?;
1069                ensure_matching_buffer_id("buffer show", requested_buffer_id, buffer.id)?;
1070                Ok(format_buffer_details(&buffer, &location))
1071            }
1072            BufferCommand::Pipe { command } => match command {
1073                BufferPipeCommand::Start {
1074                    buffer_id,
1075                    cwd,
1076                    env,
1077                    command,
1078                } => {
1079                    if command.is_empty() {
1080                        return Err(MuxError::invalid_input(
1081                            "buffer pipe command must not be empty",
1082                        ));
1083                    }
1084                    let expected = BufferId(buffer_id);
1085                    let response = connection
1086                        .request(ClientMessage::Buffer(BufferRequest::StartPipe {
1087                            request_id: new_request_id(),
1088                            buffer_id: expected,
1089                            command,
1090                            cwd,
1091                            env: env.into_iter().collect(),
1092                        }))
1093                        .await?;
1094                    match response {
1095                        ServerResponse::Buffer(response) => {
1096                            ensure_matching_buffer_id(
1097                                "buffer pipe start",
1098                                expected,
1099                                response.buffer.id,
1100                            )?;
1101                            format_buffer_pipe_details(
1102                                response.buffer.id,
1103                                response.buffer.pipe.as_ref(),
1104                            )
1105                        }
1106                        other => Err(MuxError::protocol(format!(
1107                            "unexpected response to buffer pipe start: {other:?}"
1108                        ))),
1109                    }
1110                }
1111                BufferPipeCommand::Stop { buffer_id } => {
1112                    let expected = BufferId(buffer_id);
1113                    let response = connection
1114                        .request(ClientMessage::Buffer(BufferRequest::StopPipe {
1115                            request_id: new_request_id(),
1116                            buffer_id: expected,
1117                        }))
1118                        .await?;
1119                    match response {
1120                        ServerResponse::Buffer(response) => {
1121                            ensure_matching_buffer_id(
1122                                "buffer pipe stop",
1123                                expected,
1124                                response.buffer.id,
1125                            )?;
1126                            format_buffer_pipe_details(
1127                                response.buffer.id,
1128                                response.buffer.pipe.as_ref(),
1129                            )
1130                        }
1131                        other => Err(MuxError::protocol(format!(
1132                            "unexpected response to buffer pipe stop: {other:?}"
1133                        ))),
1134                    }
1135                }
1136                BufferPipeCommand::Show { buffer_id } => {
1137                    let expected = BufferId(buffer_id);
1138                    let response = connection
1139                        .request(ClientMessage::Buffer(BufferRequest::Get {
1140                            request_id: new_request_id(),
1141                            buffer_id: expected,
1142                        }))
1143                        .await?;
1144                    match response {
1145                        ServerResponse::Buffer(response) => {
1146                            ensure_matching_buffer_id(
1147                                "buffer pipe show",
1148                                expected,
1149                                response.buffer.id,
1150                            )?;
1151                            format_buffer_pipe_details(
1152                                response.buffer.id,
1153                                response.buffer.pipe.as_ref(),
1154                            )
1155                        }
1156                        other => Err(MuxError::protocol(format!(
1157                            "unexpected response to buffer pipe show: {other:?}"
1158                        ))),
1159                    }
1160                }
1161            },
1162            BufferCommand::Reveal { buffer_id, client } => {
1163                let requested_buffer_id = BufferId(buffer_id);
1164                let response = connection
1165                    .request(ClientMessage::Buffer(BufferRequest::Reveal {
1166                        request_id: new_request_id(),
1167                        buffer_id: requested_buffer_id,
1168                        client_id: client,
1169                    }))
1170                    .await?;
1171                let location = expect_buffer_location(response, "buffer reveal")?;
1172                ensure_matching_buffer_id(
1173                    "buffer reveal",
1174                    requested_buffer_id,
1175                    location.buffer_id,
1176                )?;
1177                if matches!(location.attachment, BufferLocationAttachment::Detached) {
1178                    return Err(MuxError::conflict(format!(
1179                        "buffer {} is detached; use attach-buffer or node join-buffer",
1180                        buffer_id
1181                    )));
1182                }
1183                Ok(format_buffer_location_line(&location))
1184            }
1185            BufferCommand::History {
1186                buffer_id,
1187                scope,
1188                placement,
1189                client,
1190            } => {
1191                let requested_scope = history_scope(scope);
1192                let requested_placement = history_placement(placement);
1193                let response = connection
1194                    .request(ClientMessage::Buffer(BufferRequest::OpenHistory {
1195                        request_id: new_request_id(),
1196                        buffer_id: BufferId(buffer_id),
1197                        scope: requested_scope,
1198                        placement: requested_placement,
1199                        client_id: client,
1200                    }))
1201                    .await?;
1202                let (buffer, location, at_root_tab) =
1203                    expect_buffer_with_location(response, "buffer history")?;
1204                ensure_history_response(
1205                    BufferId(buffer_id),
1206                    requested_scope,
1207                    requested_placement,
1208                    &buffer,
1209                    &location,
1210                    at_root_tab,
1211                )?;
1212                Ok(format_buffer_location_line(&location))
1213            }
1214            BufferCommand::SetOption {
1215                target,
1216                key,
1217                value,
1218                unset,
1219            } => {
1220                let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1221                let value = if unset {
1222                    None
1223                } else {
1224                    Some(value.ok_or_else(|| {
1225                        MuxError::invalid_input(
1226                            "buffer set-option requires a value (or --unset to clear it)",
1227                        )
1228                    })?)
1229                };
1230                connection
1231                    .set_buffer_option(buffer_id, key, value, "buffer set-option")
1232                    .await?;
1233                Ok(String::new())
1234            }
1235            BufferCommand::ShowOptions { target } => {
1236                let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1237                Ok(connection
1238                    .buffer_options(buffer_id, "buffer show-options")
1239                    .await?
1240                    .iter()
1241                    .map(|(key, value)| {
1242                        // JSON-encode both fields and tab-separate them (like
1243                        // format_buffer_details) so a key or value containing
1244                        // whitespace stays one parseable line with an
1245                        // unambiguous field boundary.
1246                        format!(
1247                            "{}\t{}",
1248                            serde_json::to_string(key).expect("user option keys serialize to JSON"),
1249                            serde_json::to_string(value)
1250                                .expect("user option values serialize to JSON"),
1251                        )
1252                    })
1253                    .collect::<Vec<_>>()
1254                    .join("\n"))
1255            }
1256        },
1257        Command::Node { command } => match command {
1258            NodeCommand::Zoom { node_id } => {
1259                let response = connection
1260                    .request(ClientMessage::Node(NodeRequest::Zoom {
1261                        request_id: new_request_id(),
1262                        node_id: NodeId(node_id),
1263                    }))
1264                    .await?;
1265                expect_ok(response, "NodeCommand::Zoom")?;
1266                Ok(String::new())
1267            }
1268            NodeCommand::Unzoom { target } => {
1269                let session = connection.resolve_session_record(target.as_deref()).await?;
1270                let response = connection
1271                    .request(ClientMessage::Node(NodeRequest::Unzoom {
1272                        request_id: new_request_id(),
1273                        session_id: session.id,
1274                    }))
1275                    .await?;
1276                expect_ok(response, "NodeCommand::Unzoom")?;
1277                Ok(String::new())
1278            }
1279            NodeCommand::ToggleZoom { node_id } => {
1280                let response = connection
1281                    .request(ClientMessage::Node(NodeRequest::ToggleZoom {
1282                        request_id: new_request_id(),
1283                        node_id: NodeId(node_id),
1284                    }))
1285                    .await?;
1286                expect_ok(response, "NodeCommand::ToggleZoom")?;
1287                Ok(String::new())
1288            }
1289            NodeCommand::Swap {
1290                first_node_id,
1291                second_node_id,
1292            } => {
1293                let response = connection
1294                    .request(ClientMessage::Node(NodeRequest::SwapSiblings {
1295                        request_id: new_request_id(),
1296                        first_node_id: NodeId(first_node_id),
1297                        second_node_id: NodeId(second_node_id),
1298                    }))
1299                    .await?;
1300                expect_ok(response, "NodeCommand::Swap")?;
1301                Ok(String::new())
1302            }
1303            NodeCommand::Break {
1304                node_id,
1305                destination,
1306            } => {
1307                let response = connection
1308                    .request(ClientMessage::Node(NodeRequest::BreakNode {
1309                        request_id: new_request_id(),
1310                        node_id: NodeId(node_id),
1311                        destination: break_destination(destination),
1312                    }))
1313                    .await?;
1314                expect_ok(response, "NodeCommand::Break")?;
1315                Ok(String::new())
1316            }
1317            NodeCommand::JoinBuffer {
1318                node_id,
1319                buffer_id,
1320                placement,
1321            } => {
1322                let response = connection
1323                    .request(ClientMessage::Node(NodeRequest::JoinBufferAtNode {
1324                        request_id: new_request_id(),
1325                        node_id: NodeId(node_id),
1326                        buffer_id: BufferId(buffer_id),
1327                        placement: join_placement(placement),
1328                    }))
1329                    .await?;
1330                expect_ok(response, "NodeCommand::JoinBuffer")?;
1331                Ok(String::new())
1332            }
1333            NodeCommand::MoveBefore {
1334                node_id,
1335                sibling_id,
1336            } => {
1337                let response = connection
1338                    .request(ClientMessage::Node(NodeRequest::MoveNodeBefore {
1339                        request_id: new_request_id(),
1340                        node_id: NodeId(node_id),
1341                        sibling_node_id: NodeId(sibling_id),
1342                    }))
1343                    .await?;
1344                expect_ok(response, "NodeCommand::MoveBefore")?;
1345                Ok(String::new())
1346            }
1347            NodeCommand::MoveAfter {
1348                node_id,
1349                sibling_id,
1350            } => {
1351                let response = connection
1352                    .request(ClientMessage::Node(NodeRequest::MoveNodeAfter {
1353                        request_id: new_request_id(),
1354                        node_id: NodeId(node_id),
1355                        sibling_node_id: NodeId(sibling_id),
1356                    }))
1357                    .await?;
1358                expect_ok(response, "NodeCommand::MoveAfter")?;
1359                Ok(String::new())
1360            }
1361        },
1362        Command::NewWindow {
1363            target,
1364            title,
1365            cwd,
1366            detached,
1367            print,
1368            format,
1369            command,
1370        } => {
1371            let session = connection.resolve_session_record(target.as_deref()).await?;
1372            let command = buffer_command(command);
1373            let window_title = title.unwrap_or_else(|| default_title(&command, "window"));
1374            let snapshot = connection.session_snapshot(session.id).await?;
1375            let cwd = connection
1376                .spawn_cwd(cwd.as_deref(), Some(&snapshot))
1377                .await?;
1378            let previous_window = current_root_window(&snapshot)?;
1379            let previous_focus = snapshot.session.focused_leaf_id;
1380            let size = spawn_size(&snapshot, PanePlacement::RootTab);
1381            let buffer = connection
1382                .create_buffer(Some(window_title.clone()), command, cwd, size, session.id)
1383                .await?;
1384            let response = connection
1385                .request(ClientMessage::Session(SessionRequest::AddRootTab {
1386                    request_id: new_request_id(),
1387                    session_id: session.id,
1388                    title: window_title.clone(),
1389                    buffer_id: Some(buffer.buffer.id),
1390                    child_node_id: None,
1391                }))
1392                .await;
1393            let response = rollback_created_buffer_on_error(
1394                connection,
1395                buffer.buffer.id,
1396                "new-window",
1397                response,
1398            )
1399            .await?;
1400            let mut snapshot = rollback_created_buffer_on_error(
1401                connection,
1402                buffer.buffer.id,
1403                "new-window",
1404                expect_session_snapshot(response, "new-window"),
1405            )
1406            .await?;
1407            let (index, title) = active_root_window(&snapshot)?;
1408            let number = window_number(index as usize, connection.base_index_for(&snapshot));
1409            if detached {
1410                snapshot = restore_current(
1411                    connection,
1412                    snapshot,
1413                    previous_window,
1414                    previous_focus,
1415                    "new-window",
1416                )
1417                .await?;
1418            }
1419            if print {
1420                let pane = pane_showing(&snapshot, buffer.buffer.id);
1421                let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
1422                return connection.expand_for_pane(&snapshot, pane, &format).await;
1423            }
1424            Ok(format!("{number}\t{title}"))
1425        }
1426        Command::ListWindows {
1427            target,
1428            all,
1429            format,
1430        } => {
1431            let snapshots = if all {
1432                let mut snapshots = Vec::new();
1433                for session in connection.list_sessions().await? {
1434                    snapshots.push(connection.session_snapshot(session.id).await?);
1435                }
1436                snapshots
1437            } else {
1438                vec![
1439                    connection
1440                        .resolve_session_snapshot(target.as_deref())
1441                        .await?,
1442                ]
1443            };
1444            let mut lines = Vec::new();
1445            for snapshot in snapshots {
1446                match &format {
1447                    None => {
1448                        let base_index = connection.base_index_for(&snapshot);
1449                        lines.push(format_windows(&snapshot, base_index)?);
1450                    }
1451                    Some(format) => {
1452                        for pane in window_panes(&snapshot)? {
1453                            lines.push(connection.expand_for_pane(&snapshot, pane, format).await?);
1454                        }
1455                    }
1456                }
1457            }
1458            Ok(lines.join("\n"))
1459        }
1460        Command::SelectWindow { target } => {
1461            let window = connection.resolve_window(Some(&target)).await?;
1462            connection
1463                .request(ClientMessage::Session(SessionRequest::SelectRootTab {
1464                    request_id: new_request_id(),
1465                    session_id: window.snapshot.session.id,
1466                    index: window.index,
1467                }))
1468                .await?;
1469            Ok(String::new())
1470        }
1471        Command::RenameWindow { target, title } => {
1472            let window = connection.resolve_window(target.as_deref()).await?;
1473            connection
1474                .request(ClientMessage::Session(SessionRequest::RenameRootTab {
1475                    request_id: new_request_id(),
1476                    session_id: window.snapshot.session.id,
1477                    index: window.index,
1478                    title,
1479                }))
1480                .await?;
1481            Ok(String::new())
1482        }
1483        Command::SynchronizePanes { target, state } => {
1484            let window = connection.resolve_window(target.as_deref()).await?;
1485            let response = connection
1486                .request(ClientMessage::Session(
1487                    SessionRequest::SetRootTabSynchronizePanes {
1488                        request_id: new_request_id(),
1489                        session_id: window.snapshot.session.id,
1490                        index: window.index,
1491                        mode: match state {
1492                            None => SynchronizePanesMode::Toggle,
1493                            Some(SwitchArg::On) => SynchronizePanesMode::On,
1494                            Some(SwitchArg::Off) => SynchronizePanesMode::Off,
1495                        },
1496                    },
1497                ))
1498                .await?;
1499            let snapshot = expect_session_snapshot(response, "synchronize-panes")?;
1500            let (_, tabs) = root_tabs(&snapshot)?;
1501            let enabled = protocol_tab(tabs, window.index).is_some_and(|tab| tab.synchronize_panes);
1502            Ok(if enabled { "on" } else { "off" }.to_owned())
1503        }
1504        Command::KillWindow { target, detach } => {
1505            let window = connection.resolve_window(target.as_deref()).await?;
1506            let shown = buffer_ids_in_subtree(&window.snapshot.nodes, window.child_id);
1507            connection
1508                .request(ClientMessage::Session(SessionRequest::CloseRootTab {
1509                    request_id: new_request_id(),
1510                    session_id: window.snapshot.session.id,
1511                    index: window.index,
1512                }))
1513                .await?;
1514            if !detach {
1515                connection.kill_detached_buffers(shown).await?;
1516            }
1517            Ok(String::new())
1518        }
1519        Command::SplitWindow {
1520            target,
1521            side_by_side: _,
1522            stacked,
1523            horizontal,
1524            vertical: _,
1525            cwd,
1526            detached,
1527            print,
1528            format,
1529            help: _,
1530            command,
1531        } => {
1532            let pane = connection.resolve_pane(target.as_deref()).await?;
1533            let previous_window = current_root_window(&pane.snapshot)?;
1534            let previous_focus = pane.snapshot.session.focused_leaf_id;
1535            let command = buffer_command(command);
1536            let direction = if horizontal || stacked {
1537                SplitDirection::Horizontal
1538            } else {
1539                SplitDirection::Vertical
1540            };
1541            let cwd = connection
1542                .spawn_cwd_for_pane(cwd.as_deref(), &pane.snapshot, pane.leaf_id)
1543                .await?;
1544            let size = spawn_size(
1545                &pane.snapshot,
1546                PanePlacement::Split {
1547                    leaf_id: pane.leaf_id,
1548                    direction,
1549                },
1550            );
1551            let buffer = connection
1552                .create_buffer(
1553                    Some(default_title(&command, "pane")),
1554                    command,
1555                    cwd,
1556                    size,
1557                    pane.snapshot.session.id,
1558                )
1559                .await?;
1560            let response = connection
1561                .request(ClientMessage::Node(NodeRequest::Split {
1562                    request_id: new_request_id(),
1563                    leaf_node_id: pane.leaf_id,
1564                    direction,
1565                    new_buffer_id: buffer.buffer.id,
1566                }))
1567                .await;
1568            let response = rollback_created_buffer_on_error(
1569                connection,
1570                buffer.buffer.id,
1571                "split-window",
1572                response,
1573            )
1574            .await?;
1575            let mut snapshot = rollback_created_buffer_on_error(
1576                connection,
1577                buffer.buffer.id,
1578                "split-window",
1579                expect_session_snapshot(response, "split-window"),
1580            )
1581            .await?;
1582            let new_pane = pane_showing(&snapshot, buffer.buffer.id).ok_or_else(|| {
1583                MuxError::protocol("split-window response does not show the new pane")
1584            })?;
1585            if detached {
1586                // The split focused the new pane, and with it the target's
1587                // window. Make the target pane its window's active pane again,
1588                // then give the window and focus back, so a split of a window
1589                // that is not current leaves the current window alone.
1590                let response = connection
1591                    .request(ClientMessage::Node(NodeRequest::SelectPane {
1592                        request_id: new_request_id(),
1593                        session_id: snapshot.session.id,
1594                        node_id: pane.leaf_id,
1595                    }))
1596                    .await?;
1597                snapshot = expect_session_snapshot(response, "split-window")?;
1598                snapshot = restore_current(
1599                    connection,
1600                    snapshot,
1601                    previous_window,
1602                    previous_focus,
1603                    "split-window",
1604                )
1605                .await?;
1606            }
1607            if print {
1608                let format = format.unwrap_or_else(|| DEFAULT_PANE_FORMAT.to_owned());
1609                return connection
1610                    .expand_for_pane(&snapshot, Some(new_pane), &format)
1611                    .await;
1612            }
1613            Ok(new_pane.to_string())
1614        }
1615        Command::ListPanes {
1616            target,
1617            session,
1618            all,
1619            format,
1620        } => {
1621            let mut panes = Vec::new();
1622            if all || session {
1623                let snapshots = if all {
1624                    let mut snapshots = Vec::new();
1625                    for session in connection.list_sessions().await? {
1626                        snapshots.push(connection.session_snapshot(session.id).await?);
1627                    }
1628                    snapshots
1629                } else {
1630                    vec![
1631                        connection
1632                            .resolve_session_snapshot(target.as_deref())
1633                            .await?,
1634                    ]
1635                };
1636                for snapshot in snapshots {
1637                    let mut leaf_ids = Vec::new();
1638                    for window in window_children(&snapshot)? {
1639                        leaf_ids.extend(visible_leaf_ids(&snapshot, window)?);
1640                    }
1641                    panes.push((snapshot, leaf_ids));
1642                }
1643            } else {
1644                let window = connection.resolve_window(target.as_deref()).await?;
1645                let leaf_ids = visible_leaf_ids(&window.snapshot, window.child_id)?;
1646                panes.push((window.snapshot, leaf_ids));
1647            }
1648            let mut lines = Vec::new();
1649            for (snapshot, leaf_ids) in panes {
1650                match &format {
1651                    None => lines.push(format_panes(&snapshot, &leaf_ids)?),
1652                    Some(format) => {
1653                        for leaf_id in leaf_ids {
1654                            lines.push(
1655                                connection
1656                                    .expand_for_pane(&snapshot, Some(leaf_id), format)
1657                                    .await?,
1658                            );
1659                        }
1660                    }
1661                }
1662            }
1663            Ok(lines.join("\n"))
1664        }
1665        Command::SelectPane {
1666            target, last: true, ..
1667        } => {
1668            let session = connection.resolve_session_record(target.as_deref()).await?;
1669            connection
1670                .request(ClientMessage::Node(NodeRequest::FocusLast {
1671                    request_id: new_request_id(),
1672                    session_id: session.id,
1673                }))
1674                .await?;
1675            Ok(String::new())
1676        }
1677        Command::SelectPane {
1678            target,
1679            left,
1680            right,
1681            up,
1682            down,
1683            ..
1684        } => {
1685            let direction = [
1686                (left, NavigationDirection::Left),
1687                (right, NavigationDirection::Right),
1688                (up, NavigationDirection::Up),
1689                (down, NavigationDirection::Down),
1690            ]
1691            .into_iter()
1692            .find_map(|(set, direction)| set.then_some(direction));
1693            let pane = connection.resolve_pane(target.as_deref()).await?;
1694            let pane_id = match direction {
1695                None => pane.leaf_id,
1696                Some(direction) => {
1697                    let (_, presentation) = formats::project(&pane.snapshot, pane.leaf_id)?;
1698                    match presentation.focus_target_from(pane.leaf_id, direction) {
1699                        Some(pane_id) => pane_id,
1700                        // Nothing on that side: like the client's focus
1701                        // actions, and unlike tmux, focus does not wrap.
1702                        None => return Ok(String::new()),
1703                    }
1704                }
1705            };
1706            // Like tmux, a pane in another window becomes that window's
1707            // active pane without making the window current.
1708            connection
1709                .request(ClientMessage::Node(NodeRequest::SelectPane {
1710                    request_id: new_request_id(),
1711                    session_id: pane.snapshot.session.id,
1712                    node_id: pane_id,
1713                }))
1714                .await?;
1715            Ok(String::new())
1716        }
1717        Command::SetOption {
1718            pane,
1719            window,
1720            server,
1721            global,
1722            target,
1723            unset,
1724            only_if_unset,
1725            quiet,
1726            name,
1727            value,
1728        } => {
1729            connection
1730                .set_option(options::SetOption {
1731                    scope: options::Scope {
1732                        pane,
1733                        window,
1734                        server,
1735                        global,
1736                    },
1737                    target,
1738                    name,
1739                    value,
1740                    unset,
1741                    only_if_unset,
1742                    quiet,
1743                })
1744                .await
1745        }
1746        Command::ShowOptions {
1747            pane,
1748            window,
1749            server,
1750            global,
1751            target,
1752            value_only,
1753            quiet,
1754            name,
1755        } => {
1756            connection
1757                .show_options(options::ShowOptions {
1758                    scope: options::Scope {
1759                        pane,
1760                        window,
1761                        server,
1762                        global,
1763                    },
1764                    target,
1765                    name,
1766                    value_only,
1767                    quiet,
1768                })
1769                .await
1770        }
1771        Command::ResizePane {
1772            target,
1773            sizes,
1774            left,
1775            right,
1776            up,
1777            down,
1778            zoom,
1779            adjustment,
1780        } => {
1781            let direction = [
1782                (left, NavigationDirection::Left),
1783                (right, NavigationDirection::Right),
1784                (up, NavigationDirection::Up),
1785                (down, NavigationDirection::Down),
1786            ]
1787            .into_iter()
1788            .find_map(|(set, direction)| set.then_some(direction));
1789            if direction.is_none() && !zoom && sizes.is_empty() {
1790                return Err(MuxError::invalid_input(
1791                    "resize-pane requires -L/-R/-U/-D, -Z, or --sizes",
1792                ));
1793            }
1794            let pane = connection.resolve_pane(target.as_deref()).await?;
1795            if zoom {
1796                let response = connection
1797                    .request(ClientMessage::Node(NodeRequest::ToggleZoom {
1798                        request_id: new_request_id(),
1799                        node_id: pane.leaf_id,
1800                    }))
1801                    .await?;
1802                expect_ok(response, "resize-pane -Z")?;
1803                return Ok(String::new());
1804            }
1805            if let Some(direction) = direction {
1806                if let Some(resize) =
1807                    plan_directional_resize(&pane, direction, adjustment.unwrap_or(1))?
1808                {
1809                    connection
1810                        .request(ClientMessage::Node(NodeRequest::Resize {
1811                            request_id: new_request_id(),
1812                            node_id: resize.split_node_id,
1813                            sizes: resize.sizes,
1814                        }))
1815                        .await?;
1816                }
1817                return Ok(String::new());
1818            }
1819            let leaf = node_record(&pane.snapshot, pane.leaf_id)?;
1820            let parent_id = leaf
1821                .parent_id
1822                .ok_or_else(|| MuxError::invalid_input("pane is not inside a resizable split"))?;
1823            let parent = node_record(&pane.snapshot, parent_id)?;
1824            if parent.kind != embers_protocol::NodeRecordKind::Split {
1825                return Err(MuxError::invalid_input(
1826                    "pane parent is not a split and cannot be resized",
1827                ));
1828            }
1829
1830            connection
1831                .request(ClientMessage::Node(NodeRequest::Resize {
1832                    request_id: new_request_id(),
1833                    node_id: parent_id,
1834                    sizes,
1835                }))
1836                .await?;
1837            Ok(String::new())
1838        }
1839        Command::SendKeys {
1840            target,
1841            enter,
1842            keys,
1843        } => {
1844            let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1845            if keys.is_empty() && !enter {
1846                return Err(MuxError::invalid_input(
1847                    "send-keys requires at least one key or --enter",
1848                ));
1849            }
1850            let mut bytes = keys.join(" ").into_bytes();
1851            if enter {
1852                bytes.push(b'\r');
1853            }
1854            connection
1855                .request(ClientMessage::Input(embers_protocol::InputRequest::Send {
1856                    request_id: new_request_id(),
1857                    buffer_id,
1858                    bytes,
1859                }))
1860                .await?;
1861            Ok(String::new())
1862        }
1863        Command::CapturePane {
1864            target,
1865            print,
1866            start,
1867            end,
1868            join,
1869            escapes,
1870        } => {
1871            let buffer_id = connection.resolve_buffer(target.as_deref()).await?;
1872            if print || start.is_some() || end.is_some() || join || escapes {
1873                return connection
1874                    .capture_lines(
1875                        buffer_id,
1876                        capture::CaptureOptions {
1877                            start,
1878                            end,
1879                            join,
1880                            escapes,
1881                        },
1882                    )
1883                    .await;
1884            }
1885            let response = connection
1886                .request(ClientMessage::Buffer(BufferRequest::Capture {
1887                    request_id: new_request_id(),
1888                    buffer_id,
1889                }))
1890                .await?;
1891            let snapshot = expect_capture(response, "capture-pane")?;
1892            Ok(snapshot.lines.join("\n"))
1893        }
1894        Command::KillPane { target, detach } => {
1895            let pane = connection.resolve_pane(target.as_deref()).await?;
1896            connection
1897                .request(ClientMessage::Node(NodeRequest::Close {
1898                    request_id: new_request_id(),
1899                    node_id: pane.leaf_id,
1900                }))
1901                .await?;
1902            if !detach {
1903                connection
1904                    .kill_detached_buffers(vec![pane.buffer_id])
1905                    .await?;
1906            }
1907            Ok(String::new())
1908        }
1909        Command::DisplayPopup {
1910            target,
1911            title,
1912            x,
1913            y,
1914            width,
1915            height,
1916            close_on_exit,
1917            cwd,
1918            print,
1919            format,
1920            no_wait,
1921            no_border,
1922            help: _,
1923            command,
1924        } => {
1925            // The popup goes over the target pane's session, which formats
1926            // are expanded for; a session without panes takes it too.
1927            let (snapshot, pane_id) = match connection.resolve_pane(target.as_deref()).await {
1928                Ok(pane) => (pane.snapshot, Some(pane.leaf_id)),
1929                Err(error) => match target.as_deref() {
1930                    Some(target) => (
1931                        connection.resolve_session_snapshot(Some(target)).await?,
1932                        None,
1933                    ),
1934                    None => return Err(error),
1935                },
1936            };
1937            let session_id = snapshot.session.id;
1938            let command = buffer_command(command);
1939            let popup_title = match title {
1940                Some(title) => {
1941                    connection
1942                        .expand_for_pane(&snapshot, pane_id, &title)
1943                        .await?
1944                }
1945                None => default_title(&command, "popup"),
1946            };
1947            let cwd = match pane_id {
1948                Some(pane_id) => {
1949                    connection
1950                        .spawn_cwd_for_pane(cwd.as_deref(), &snapshot, pane_id)
1951                        .await?
1952                }
1953                None => {
1954                    connection
1955                        .spawn_cwd(cwd.as_deref(), Some(&snapshot))
1956                        .await?
1957                }
1958            };
1959            let geometry =
1960                popup::popup_geometry(formats::session_viewport(&snapshot), width, height, x, y);
1961            let wait = connection.wait_for_popups && !no_wait;
1962            let close = if close_on_exit >= 2 {
1963                popup::CloseOnExit::OnSuccess
1964            } else {
1965                popup::CloseOnExit::Always
1966            };
1967            let border = !no_border;
1968            let size = spawn_size(&snapshot, PanePlacement::Floating { geometry, border });
1969            let buffer = connection
1970                .create_buffer(Some(popup_title.clone()), command, cwd, size, session_id)
1971                .await?;
1972            // Keep the buffer once its command exits, so a waiting CLI can
1973            // read the exit status; `-EE` keeps it after a failure.
1974            let remain_on_exit = match (wait, close) {
1975                (true, _) => Some("on"),
1976                (false, popup::CloseOnExit::OnSuccess) => Some("failed"),
1977                (false, popup::CloseOnExit::Always) => None,
1978            };
1979            if let Some(value) = remain_on_exit {
1980                let response = connection
1981                    .request(ClientMessage::Buffer(BufferRequest::SetUserOption {
1982                        request_id: new_request_id(),
1983                        buffer_id: buffer.buffer.id,
1984                        key: popup::REMAIN_ON_EXIT.to_owned(),
1985                        value: Some(value.to_owned()),
1986                    }))
1987                    .await;
1988                rollback_created_buffer_on_error(
1989                    connection,
1990                    buffer.buffer.id,
1991                    "display-popup",
1992                    response,
1993                )
1994                .await?;
1995            }
1996            let response = connection
1997                .request(ClientMessage::Floating(FloatingRequest::Create {
1998                    request_id: new_request_id(),
1999                    session_id,
2000                    root_node_id: None,
2001                    buffer_id: Some(buffer.buffer.id),
2002                    geometry,
2003                    title: Some(popup_title),
2004                    focus: true,
2005                    close_on_empty: true,
2006                    border,
2007                }))
2008                .await;
2009            let response = rollback_created_buffer_on_error(
2010                connection,
2011                buffer.buffer.id,
2012                "display-popup",
2013                response,
2014            )
2015            .await?;
2016            let popup = rollback_created_buffer_on_error(
2017                connection,
2018                buffer.buffer.id,
2019                "display-popup",
2020                expect_floating(response, "display-popup"),
2021            )
2022            .await?;
2023            let output = if print {
2024                let snapshot = connection.session_snapshot(session_id).await?;
2025                let pane = pane_showing(&snapshot, buffer.buffer.id);
2026                let format = format
2027                    .unwrap_or_else(|| "#{popup_id}".to_owned())
2028                    .replace("#{popup_id}", &popup.id.to_string());
2029                connection.expand_for_pane(&snapshot, pane, &format).await?
2030            } else if wait {
2031                String::new()
2032            } else {
2033                popup.id.to_string()
2034            };
2035            if wait {
2036                if !output.is_empty() {
2037                    println!("{output}");
2038                }
2039                let socket = connection.socket_path.clone();
2040                let status = connection
2041                    .wait_for_popup(
2042                        &socket,
2043                        popup::WaitedPopup {
2044                            session_id,
2045                            floating_id: popup.id,
2046                            buffer_id: buffer.buffer.id,
2047                            close,
2048                        },
2049                    )
2050                    .await?;
2051                connection.exit_status = Some(status);
2052                return Ok(String::new());
2053            }
2054            Ok(output)
2055        }
2056        Command::DisplayMessage {
2057            target,
2058            client,
2059            print,
2060            duration,
2061            message,
2062        } => {
2063            let client = client.or(connection.default_client);
2064            let template = if message.is_empty() {
2065                DEFAULT_DISPLAY_MESSAGE.to_owned()
2066            } else {
2067                message.join(" ")
2068            };
2069            let pane = match (&target, client) {
2070                (None, Some(client_id)) => {
2071                    let session_id = connection
2072                        .client_record(client_id)
2073                        .await?
2074                        .current_session_id
2075                        .ok_or_else(|| {
2076                            MuxError::not_found(format!("client {client_id} shows no session"))
2077                        })?;
2078                    let snapshot = connection.session_snapshot(session_id).await?;
2079                    let pane_id = snapshot
2080                        .session
2081                        .focused_leaf_id
2082                        .ok_or_else(|| MuxError::not_found("session has no focused pane"))?;
2083                    resolved_pane(snapshot, pane_id)?
2084                }
2085                _ => connection.resolve_pane(target.as_deref()).await?,
2086            };
2087            let session_id = pane.snapshot.session.id;
2088            let text = connection
2089                .expand_for_pane(&pane.snapshot, Some(pane.leaf_id), &template)
2090                .await?;
2091            if print {
2092                return Ok(text);
2093            }
2094            let response = connection
2095                .request(ClientMessage::Client(ClientRequest::DisplayMessage {
2096                    request_id: new_request_id(),
2097                    client_id: client,
2098                    session_id: client.is_none().then_some(session_id),
2099                    message: text,
2100                    duration_ms: duration.unwrap_or(0),
2101                }))
2102                .await?;
2103            expect_ok(response, "display-message")?;
2104            Ok(String::new())
2105        }
2106        Command::SetEnvironment {
2107            target,
2108            global,
2109            unset,
2110            name,
2111            value,
2112        } => {
2113            if !unset && value.is_none() {
2114                return Err(MuxError::invalid_input(format!(
2115                    "set-environment {name} requires a value (or -u to unset it)"
2116                )));
2117            }
2118            let session_id = if global {
2119                None
2120            } else {
2121                Some(
2122                    connection
2123                        .resolve_session_record(target.as_deref())
2124                        .await?
2125                        .id,
2126                )
2127            };
2128            let response = connection
2129                .request(ClientMessage::Session(SessionRequest::SetEnvironment {
2130                    request_id: new_request_id(),
2131                    session_id,
2132                    name,
2133                    value,
2134                }))
2135                .await?;
2136            expect_ok(response, "set-environment")?;
2137            Ok(String::new())
2138        }
2139        Command::ShowEnvironment {
2140            target,
2141            global,
2142            name,
2143        } => {
2144            let session_id = if global {
2145                None
2146            } else {
2147                Some(
2148                    connection
2149                        .resolve_session_record(target.as_deref())
2150                        .await?
2151                        .id,
2152                )
2153            };
2154            let env = match connection
2155                .request(ClientMessage::Session(SessionRequest::ShowEnvironment {
2156                    request_id: new_request_id(),
2157                    session_id,
2158                }))
2159                .await?
2160            {
2161                ServerResponse::Environment(response) => response.env,
2162                other => {
2163                    return Err(MuxError::protocol(format!(
2164                        "unexpected response to show-environment: {other:?}"
2165                    )));
2166                }
2167            };
2168            match name {
2169                Some(name) => env
2170                    .get(&name)
2171                    .map(|value| format!("{name}={value}"))
2172                    .ok_or_else(|| MuxError::not_found(format!("unknown variable: {name}"))),
2173                None => Ok(env
2174                    .iter()
2175                    .map(|(name, value)| format!("{name}={value}"))
2176                    .collect::<Vec<_>>()
2177                    .join("\n")),
2178            }
2179        }
2180        Command::KillPopup { target, detach } => {
2181            let popup = connection.resolve_popup(target.as_deref()).await?;
2182            let snapshot = connection.session_snapshot(popup.session_id).await?;
2183            let shown = buffer_ids_in_subtree(&snapshot.nodes, popup.root_node_id);
2184            connection
2185                .request(ClientMessage::Floating(FloatingRequest::Close {
2186                    request_id: new_request_id(),
2187                    floating_id: popup.id,
2188                }))
2189                .await?;
2190            if !detach {
2191                connection.kill_detached_buffers(shown).await?;
2192            }
2193            Ok(String::new())
2194        }
2195    }
2196}
2197
2198pub async fn run(cli: Cli) -> Result<()> {
2199    let log_filter = cli.log_filter();
2200    let base_index = cli.base_index();
2201    let Cli {
2202        socket,
2203        config,
2204        command,
2205        ..
2206    } = cli;
2207
2208    match command {
2209        Some(Command::RuntimeKeeper {
2210            keeper_socket,
2211            cols,
2212            rows,
2213            pixel_width,
2214            pixel_height,
2215            cwd,
2216            env,
2217            command,
2218        }) => embers_server::run_runtime_keeper(embers_server::RuntimeKeeperCli {
2219            socket_path: keeper_socket,
2220            command,
2221            cwd,
2222            env: env.into_iter().collect(),
2223            size: embers_core::PtySize {
2224                cols,
2225                rows,
2226                pixel_width,
2227                pixel_height,
2228            },
2229        }),
2230        command => {
2231            let socket = resolve_socket_path(socket.as_deref());
2232            validate_runtime_socket_parent(&socket)?;
2233
2234            match command {
2235                None => {
2236                    ensure_server_process(&socket, &log_filter).await?;
2237                    interactive::run(socket, None, config).await
2238                }
2239                Some(Command::Attach { target }) => {
2240                    if !server_is_available(&socket).await {
2241                        return Err(MuxError::not_found(format!(
2242                            "no embers server is listening on {}",
2243                            socket.display()
2244                        )));
2245                    }
2246                    let target = match target {
2247                        Some(target) => Some(
2248                            CliConnection::connect(&socket)
2249                                .await?
2250                                .resolve_session_record(Some(&target))
2251                                .await?
2252                                .name,
2253                        ),
2254                        None => None,
2255                    };
2256                    interactive::run(socket, target, config).await
2257                }
2258                Some(Command::Automation {
2259                    target,
2260                    all_sessions,
2261                }) => {
2262                    ensure_server_process(&socket, &log_filter).await?;
2263                    automation::run(socket, target, all_sessions).await
2264                }
2265                Some(Command::Serve) => run_server(socket, &log_filter).await,
2266                Some(Command::KillServer) => kill_server(&socket).await,
2267                Some(command) => {
2268                    ensure_server_process(&socket, &log_filter).await?;
2269                    let outcome = execute(&socket, command, base_index).await?;
2270                    if !outcome.output.is_empty() {
2271                        println!("{}", outcome.output);
2272                    }
2273                    if let Some(session) = outcome.attach_session {
2274                        return interactive::run(socket, Some(session), config).await;
2275                    }
2276                    if let Some(status) = outcome.exit_status.filter(|status| *status != 0) {
2277                        std::io::stdout().flush()?;
2278                        std::process::exit(status);
2279                    }
2280                    Ok(())
2281                }
2282            }
2283        }
2284    }
2285}
2286
2287fn resolve_socket_path(explicit: Option<&Path>) -> PathBuf {
2288    explicit
2289        .map(Path::to_path_buf)
2290        .or_else(|| std::env::var_os(SOCKET_ENV_VAR).map(PathBuf::from))
2291        .unwrap_or_else(default_socket_path)
2292}
2293
2294fn default_socket_path() -> PathBuf {
2295    default_runtime_dir().join("embers.sock")
2296}
2297
2298fn default_runtime_dir() -> PathBuf {
2299    if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty())
2300    {
2301        return PathBuf::from(runtime_dir).join("embers");
2302    }
2303    #[cfg(unix)]
2304    {
2305        let run_user_dir = PathBuf::from(format!("/run/user/{}", effective_uid()));
2306        if run_user_dir.is_dir() {
2307            return run_user_dir.join("embers");
2308        }
2309    }
2310    PathBuf::from("/tmp").join(format!("embers-{}", effective_uid()))
2311}
2312
2313fn parse_env_arg(value: &str) -> std::result::Result<(String, OsString), String> {
2314    let Some((key, env_value)) = value.split_once('=') else {
2315        return Err("expected KEY=VALUE".to_owned());
2316    };
2317    if key.is_empty() {
2318        return Err("environment key must not be empty".to_owned());
2319    }
2320    Ok((key.to_owned(), decode_runtime_keeper_env_value(env_value)?))
2321}
2322
2323fn parse_string_env_arg(value: &str) -> std::result::Result<(String, String), String> {
2324    let Some((key, env_value)) = value.split_once('=') else {
2325        return Err("expected KEY=VALUE".to_owned());
2326    };
2327    if key.is_empty() {
2328        return Err("environment key must not be empty".to_owned());
2329    }
2330    Ok((key.to_owned(), env_value.to_owned()))
2331}
2332
2333fn decode_runtime_keeper_env_value(value: &str) -> std::result::Result<OsString, String> {
2334    let Some(encoded) = value.strip_prefix("base64:") else {
2335        return Ok(OsString::from(value));
2336    };
2337    let decoded = base64::engine::general_purpose::STANDARD
2338        .decode(encoded)
2339        .map_err(|error| format!("invalid base64 environment value: {error}"))?;
2340    #[cfg(unix)]
2341    {
2342        Ok(OsString::from_vec(decoded))
2343    }
2344    #[cfg(windows)]
2345    {
2346        if decoded.len() % 2 != 0 {
2347            return Err("invalid UTF-16LE environment value: odd-length byte sequence".to_owned());
2348        }
2349        let wide = decoded
2350            .chunks_exact(2)
2351            .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
2352            .collect::<Vec<_>>();
2353        Ok(OsString::from_wide(&wide))
2354    }
2355    #[cfg(all(not(unix), not(windows)))]
2356    {
2357        String::from_utf8(decoded)
2358            .map(OsString::from)
2359            .map_err(|error| format!("invalid UTF-8 environment value: {error}"))
2360    }
2361}
2362
2363#[cfg(unix)]
2364fn effective_uid() -> u32 {
2365    unsafe { libc::geteuid() }
2366}
2367
2368#[cfg(not(unix))]
2369fn effective_uid() -> u32 {
2370    0
2371}
2372
2373fn pid_path(socket_path: &Path) -> PathBuf {
2374    socket_path.with_extension("pid")
2375}
2376
2377async fn server_is_available(socket_path: &Path) -> bool {
2378    if validate_runtime_socket_parent(socket_path).is_err() {
2379        return false;
2380    }
2381    CliConnection::connect(socket_path).await.is_ok()
2382}
2383
2384async fn ensure_server_process(socket_path: &Path, log_filter: &str) -> Result<()> {
2385    if server_is_available(socket_path).await {
2386        return Ok(());
2387    }
2388
2389    ensure_socket_parent(socket_path)?;
2390
2391    let current_exe = std::env::current_exe()?;
2392    let mut child = ProcessCommand::new(current_exe)
2393        .arg("__serve")
2394        .arg("--socket")
2395        .arg(socket_path)
2396        // Propagate the resolved filter so a `--log`/`-v` flag reaches the
2397        // detached server, not just the inherited EMBERS_LOG/RUST_LOG env. The
2398        // server writes its own rotating log file, so its stdio is discarded.
2399        .env(EMBERS_LOG_ENV_VAR, log_filter)
2400        .stdin(Stdio::null())
2401        .stdout(Stdio::null())
2402        .stderr(Stdio::null())
2403        .spawn()?;
2404
2405    let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
2406    let mut exited_status = None;
2407    loop {
2408        if server_is_available(socket_path).await {
2409            return Ok(());
2410        }
2411        if let Some(status) = child.try_wait()? {
2412            exited_status.get_or_insert(status);
2413            if server_is_available(socket_path).await {
2414                return Ok(());
2415            }
2416        }
2417        if tokio::time::Instant::now() >= deadline {
2418            if let Some(status) = exited_status {
2419                return Err(MuxError::transport(format!(
2420                    "embers server exited before becoming ready with status {status}"
2421                )));
2422            }
2423            let _ = child.kill();
2424            let _ = child.wait();
2425            return Err(MuxError::timeout(format!(
2426                "timed out waiting for embers server at {}",
2427                socket_path.display()
2428            )));
2429        }
2430        sleep(Duration::from_millis(25)).await;
2431    }
2432}
2433
2434async fn run_server(socket_path: PathBuf, log_filter: &str) -> Result<()> {
2435    ensure_socket_parent(&socket_path)?;
2436    // The detached server logs to a daily-rotating file next to the socket. Set
2437    // this up before anything else so startup is captured, then route panics
2438    // through tracing so a crash lands in the same log instead of a dead stderr.
2439    if let Some(dir) = socket_path.parent() {
2440        embers_core::init_server_tracing(log_filter, dir)?;
2441        install_server_panic_hook();
2442    }
2443    let secure_parent = socket_path
2444        .parent()
2445        .is_some_and(|parent| parent == default_runtime_dir().as_path());
2446    let _pid = ServerPidFile::create(&pid_path(&socket_path), secure_parent)?;
2447    let handle = Server::new(ServerConfig::new(socket_path)).start().await?;
2448    // Stop on a signal, or when a client stops the server (kill-server).
2449    tokio::select! {
2450        result = wait_for_shutdown_signal() => result?,
2451        () = handle.stopped() => {}
2452    }
2453    handle.shutdown().await
2454}
2455
2456/// How long `kill-server` waits for the server to stop listening.
2457const KILL_SERVER_TIMEOUT: Duration = Duration::from_secs(10);
2458
2459/// Ask the server on `socket_path` to stop and end every program it runs, then
2460/// wait until it no longer accepts connections, so a following command does
2461/// not reach the old server. Never starts a server.
2462async fn kill_server(socket_path: &Path) -> Result<()> {
2463    if !server_is_available(socket_path).await {
2464        return Err(MuxError::not_found(format!(
2465            "no embers server is listening on {}",
2466            socket_path.display()
2467        )));
2468    }
2469    execute(socket_path, Command::KillServer, None).await?;
2470    let deadline = tokio::time::Instant::now() + KILL_SERVER_TIMEOUT;
2471    while server_is_available(socket_path).await {
2472        if tokio::time::Instant::now() >= deadline {
2473            return Err(MuxError::timeout(format!(
2474                "embers server on {} did not stop",
2475                socket_path.display()
2476            )));
2477        }
2478        sleep(Duration::from_millis(25)).await;
2479    }
2480    Ok(())
2481}
2482
2483/// Routes panics in the detached server through `tracing` (chained after the
2484/// default hook) so a crash is recorded in the rotating log file, which is the
2485/// server's only output sink once its stdio is discarded.
2486fn install_server_panic_hook() {
2487    let previous = std::panic::take_hook();
2488    std::panic::set_hook(Box::new(move |info| {
2489        tracing::error!(target: "panic", "server panicked: {info}");
2490        previous(info);
2491    }));
2492}
2493
2494fn ensure_socket_parent(socket_path: &Path) -> Result<()> {
2495    let Some(parent) = socket_path.parent() else {
2496        return Ok(());
2497    };
2498    if parent == default_runtime_dir().as_path() {
2499        ensure_private_dir(parent)
2500    } else {
2501        fs::create_dir_all(parent)?;
2502        Ok(())
2503    }
2504}
2505
2506fn ensure_private_dir(path: &Path) -> Result<()> {
2507    fs::create_dir_all(path)?;
2508    #[cfg(unix)]
2509    {
2510        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
2511        validate_private_dir(path)?;
2512    }
2513    Ok(())
2514}
2515
2516fn validate_runtime_socket_parent(socket_path: &Path) -> Result<()> {
2517    let Some(parent) = socket_path.parent() else {
2518        return Ok(());
2519    };
2520    if parent != default_runtime_dir().as_path() || !parent.exists() {
2521        return Ok(());
2522    }
2523    validate_private_dir(parent)
2524}
2525
2526#[cfg(unix)]
2527fn validate_private_dir(path: &Path) -> Result<()> {
2528    let metadata = fs::metadata(path)?;
2529    if !metadata.is_dir() {
2530        return Err(MuxError::invalid_input(format!(
2531            "runtime directory {} is not a directory",
2532            path.display()
2533        )));
2534    }
2535    if metadata.uid() != effective_uid() {
2536        return Err(MuxError::invalid_input(format!(
2537            "runtime directory {} is not owned by uid {}",
2538            path.display(),
2539            effective_uid()
2540        )));
2541    }
2542    if metadata.permissions().mode() & 0o777 != 0o700 {
2543        return Err(MuxError::invalid_input(format!(
2544            "runtime directory {} must have mode 0700",
2545            path.display()
2546        )));
2547    }
2548    Ok(())
2549}
2550
2551#[cfg(not(unix))]
2552fn validate_private_dir(_path: &Path) -> Result<()> {
2553    Ok(())
2554}
2555
2556#[cfg(unix)]
2557async fn wait_for_shutdown_signal() -> Result<()> {
2558    let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2559    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2560    let mut hangup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
2561    tokio::select! {
2562        _ = interrupt.recv() => Ok(()),
2563        _ = terminate.recv() => Ok(()),
2564        _ = hangup.recv() => Ok(()),
2565    }
2566}
2567
2568#[cfg(not(unix))]
2569async fn wait_for_shutdown_signal() -> Result<()> {
2570    tokio::signal::ctrl_c().await?;
2571    Ok(())
2572}
2573
2574struct ServerPidFile {
2575    path: PathBuf,
2576}
2577
2578impl ServerPidFile {
2579    fn create(path: &Path, secure_parent: bool) -> Result<Self> {
2580        if let Some(parent) = path.parent() {
2581            if secure_parent {
2582                ensure_private_dir(parent)?;
2583            } else {
2584                fs::create_dir_all(parent)?;
2585            }
2586        }
2587        match fs::symlink_metadata(path) {
2588            Ok(metadata) => {
2589                if metadata.file_type().is_symlink() {
2590                    return Err(MuxError::conflict(format!(
2591                        "refusing to overwrite symlink pid file {}",
2592                        path.display()
2593                    )));
2594                }
2595                let pid_text = fs::read_to_string(path).map_err(|error| {
2596                    MuxError::conflict(format!(
2597                        "refusing to overwrite unreadable pid file {}: {error}",
2598                        path.display()
2599                    ))
2600                })?;
2601                let pid = pid_text.trim().parse::<u32>().map_err(|error| {
2602                    MuxError::conflict(format!(
2603                        "refusing to overwrite invalid pid file {}: {error}",
2604                        path.display()
2605                    ))
2606                })?;
2607                if process_is_alive(pid)? {
2608                    return Err(MuxError::conflict(format!(
2609                        "refusing to overwrite active pid file {} for running process {pid}",
2610                        path.display()
2611                    )));
2612                }
2613                fs::remove_file(path)?;
2614            }
2615            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2616            Err(error) => return Err(error.into()),
2617        }
2618
2619        #[cfg(unix)]
2620        let mut file = OpenOptions::new()
2621            .write(true)
2622            .create_new(true)
2623            .mode(0o600)
2624            .custom_flags(libc::O_NOFOLLOW)
2625            .open(path)?;
2626
2627        #[cfg(not(unix))]
2628        let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
2629
2630        file.write_all(std::process::id().to_string().as_bytes())?;
2631        Ok(Self {
2632            path: path.to_path_buf(),
2633        })
2634    }
2635}
2636
2637#[cfg(unix)]
2638fn process_is_alive(pid: u32) -> Result<bool> {
2639    let result = unsafe { libc::kill(pid as i32, 0) };
2640    if result == 0 {
2641        return Ok(true);
2642    }
2643    match std::io::Error::last_os_error().raw_os_error() {
2644        Some(libc::ESRCH) => Ok(false),
2645        Some(libc::EPERM) => Ok(true),
2646        Some(code) => Err(MuxError::transport(format!(
2647            "failed to validate process {pid}: os error {code}"
2648        ))),
2649        None => Err(MuxError::transport(format!(
2650            "failed to validate process {pid}: unknown os error"
2651        ))),
2652    }
2653}
2654
2655#[cfg(not(unix))]
2656fn process_is_alive(_pid: u32) -> Result<bool> {
2657    Err(MuxError::conflict(
2658        "pid file validation is unsupported on this platform".to_owned(),
2659    ))
2660}
2661
2662impl Drop for ServerPidFile {
2663    fn drop(&mut self) {
2664        let _ = std::fs::remove_file(&self.path);
2665    }
2666}
2667
2668/// Runs command prompt lines (tmux `:`) for the interactive client. The words
2669/// are parsed with the CLI's own definitions and executed like CLI commands
2670/// over a connection of their own, with the client's session and the client
2671/// itself as the default targets.
2672pub struct PromptCommandRunner {
2673    socket_path: PathBuf,
2674}
2675
2676impl PromptCommandRunner {
2677    pub fn new(socket_path: PathBuf) -> Self {
2678        Self { socket_path }
2679    }
2680}
2681
2682#[async_trait::async_trait]
2683impl CommandRunner for PromptCommandRunner {
2684    async fn run(&self, argv: Vec<String>, context: CommandContext) -> Result<String> {
2685        let cli = Cli::try_parse_from(std::iter::once("embers".to_owned()).chain(argv))
2686            .map_err(|error| MuxError::invalid_input(clap_error_summary(&error)))?;
2687        if let Some(flag) = prompt_ignored_global_flag(&cli) {
2688            return Err(MuxError::invalid_input(format!(
2689                "{flag} cannot be used from the command prompt"
2690            )));
2691        }
2692        let base_index = cli.base_index.or(context.base_index);
2693        let command = cli
2694            .command
2695            .ok_or_else(|| MuxError::invalid_input("no command given"))?;
2696        if let Some(name) = interactive_command_name(&command) {
2697            return Err(MuxError::invalid_input(format!(
2698                "{name} cannot be run from the command prompt"
2699            )));
2700        }
2701        let mut connection = CliConnection::connect(&self.socket_path).await?;
2702        connection.base_index = base_index;
2703        connection.default_session = context.session_id;
2704        connection.default_client = context.client_id.and_then(NonZeroU64::new);
2705        execute_command(&mut connection, command).await
2706    }
2707}
2708
2709/// A global flag a prompt line gave that the prompt cannot honour: it always
2710/// talks to the attached server, with the client's config and logging. Such
2711/// lines are refused rather than run with the flag silently dropped.
2712fn prompt_ignored_global_flag(cli: &Cli) -> Option<&'static str> {
2713    if cli.socket.is_some() {
2714        Some("--socket")
2715    } else if cli.config.is_some() {
2716        Some("--config")
2717    } else if cli.log.is_some() {
2718        Some("--log")
2719    } else if cli.verbose != 0 || cli.verbose_short != 0 {
2720        Some("--verbose")
2721    } else {
2722        None
2723    }
2724}
2725
2726/// The one-line gist of a clap error, for the status line.
2727fn clap_error_summary(error: &clap::Error) -> String {
2728    let rendered = error.to_string();
2729    let line = rendered.lines().next().unwrap_or_default();
2730    line.strip_prefix("error: ").unwrap_or(line).to_owned()
2731}
2732
2733fn interactive_command_name(command: &Command) -> Option<&'static str> {
2734    match command {
2735        Command::Attach { .. } => Some("attach"),
2736        Command::Serve => Some("__serve"),
2737        Command::RuntimeKeeper { .. } => Some("__runtime-keeper"),
2738        Command::Automation { .. } => Some("automation"),
2739        _ => None,
2740    }
2741}
2742
2743#[derive(Debug)]
2744struct CliConnection {
2745    client: ProtocolClient,
2746    /// Window base index (tmux `base-index`) from `--base-index` or
2747    /// `EMBERS_BASE_INDEX`. `None` uses the one the session's client reported
2748    /// to the server, if any, else the legacy numbering described in
2749    /// [`embers_client::numbering`].
2750    base_index: Option<u32>,
2751    /// The session commands without a session target apply to, when set by
2752    /// the command prompt; otherwise such commands need the only session.
2753    default_session: Option<SessionId>,
2754    /// The client that client commands apply to by default, when set by the
2755    /// command prompt.
2756    default_client: Option<NonZeroU64>,
2757    /// The buffer of the pane the command runs in, when it runs inside a pane
2758    /// of this server (see [`pane_buffer`]). Commands given no target act on
2759    /// that pane, its window and its session, like tmux with `TMUX_PANE`.
2760    pane_buffer: Option<BufferId>,
2761    /// The client a `run_shell` child runs for (`EMBERS_CLIENT`), for client
2762    /// commands given no client.
2763    caller_client: Option<NonZeroU64>,
2764    socket_path: PathBuf,
2765    /// The directory the command runs in, where it creates panes when not
2766    /// given `-c`. `None` (from the command prompt) uses the server's.
2767    working_dir: Option<String>,
2768    /// Whether `display-popup` waits for the popup to close: when run from a
2769    /// shell, not from the command prompt or automation.
2770    wait_for_popups: bool,
2771    /// Whether `new-session` may attach this process's terminal once done.
2772    can_attach: bool,
2773    /// Set by `new-session`: the session to attach to.
2774    attach_session: Option<String>,
2775    /// Set by a waiting `display-popup`: the status to exit with.
2776    exit_status: Option<i32>,
2777}
2778
2779/// The buffer a command run inside a pane belongs to: `EMBERS_BUFFER`, which
2780/// the server sets in every pane, taken only when the pane's `EMBERS_SOCKET`
2781/// is the socket the command talks to. A command aimed at another server, or
2782/// run outside any pane, has none.
2783fn pane_buffer(
2784    buffer: Option<&str>,
2785    pane_socket: Option<&OsStr>,
2786    socket: &Path,
2787) -> Option<BufferId> {
2788    let buffer_id = buffer?.trim().parse::<u64>().ok()?;
2789    (Path::new(pane_socket?) == socket).then_some(BufferId(buffer_id))
2790}
2791
2792impl CliConnection {
2793    async fn connect(path: impl AsRef<Path>) -> Result<Self> {
2794        let client = ProtocolClient::connect(path.as_ref())
2795            .await
2796            .map_err(|error| MuxError::transport(error.to_string()))?;
2797        Ok(Self {
2798            client,
2799            base_index: None,
2800            default_session: None,
2801            default_client: None,
2802            pane_buffer: None,
2803            caller_client: None,
2804            socket_path: path.as_ref().to_path_buf(),
2805            working_dir: None,
2806            wait_for_popups: false,
2807            can_attach: false,
2808            attach_session: None,
2809            exit_status: None,
2810        })
2811    }
2812
2813    /// The base index windows of `snapshot`'s session are numbered from:
2814    /// `--base-index` or `EMBERS_BASE_INDEX` when given, else the one the
2815    /// client showing the session reported to the server.
2816    fn base_index_for(&self, snapshot: &SessionSnapshot) -> Option<u32> {
2817        self.base_index.or(snapshot.session.base_index)
2818    }
2819
2820    /// Where the pane the command runs in is shown, if it runs in one.
2821    async fn pane_location(&mut self) -> Result<Option<BufferLocation>> {
2822        let Some(buffer_id) = self.pane_buffer else {
2823            return Ok(None);
2824        };
2825        let response = self
2826            .request(ClientMessage::Buffer(BufferRequest::GetLocation {
2827                request_id: new_request_id(),
2828                buffer_id,
2829            }))
2830            .await?;
2831        expect_buffer_location(response, "buffer location").map(Some)
2832    }
2833
2834    /// The buffer a buffer-level command acts on: the target pane's, else the
2835    /// buffer of the pane the command runs in (shown or not), else the
2836    /// focused pane's.
2837    async fn resolve_buffer(&mut self, target: Option<&str>) -> Result<BufferId> {
2838        match (target, self.pane_buffer) {
2839            (None, Some(buffer_id)) => Ok(buffer_id),
2840            _ => Ok(self.resolve_pane(target).await?.buffer_id),
2841        }
2842    }
2843
2844    async fn request(&mut self, message: ClientMessage) -> Result<ServerResponse> {
2845        match self
2846            .client
2847            .request(&message)
2848            .await
2849            .map_err(|error| MuxError::transport(error.to_string()))?
2850        {
2851            ServerResponse::Error(response) => Err(response.error.into()),
2852            response => Ok(response),
2853        }
2854    }
2855
2856    async fn list_sessions(&mut self) -> Result<Vec<SessionRecord>> {
2857        match self
2858            .request(ClientMessage::Session(SessionRequest::List {
2859                request_id: new_request_id(),
2860            }))
2861            .await?
2862        {
2863            ServerResponse::Sessions(response) => Ok(response.sessions),
2864            other => Err(MuxError::protocol(format!(
2865                "unexpected response to list-sessions: {other:?}"
2866            ))),
2867        }
2868    }
2869
2870    async fn list_clients(&mut self) -> Result<Vec<ClientRecord>> {
2871        match self
2872            .request(ClientMessage::Client(ClientRequest::List {
2873                request_id: new_request_id(),
2874            }))
2875            .await?
2876        {
2877            ServerResponse::Clients(response) => Ok(response.clients),
2878            other => Err(MuxError::protocol(format!(
2879                "unexpected response to list-clients: {other:?}"
2880            ))),
2881        }
2882    }
2883
2884    async fn client_record(&mut self, client_id: NonZeroU64) -> Result<ClientRecord> {
2885        match self
2886            .request(ClientMessage::Client(ClientRequest::Get {
2887                request_id: new_request_id(),
2888                client_id: Some(client_id),
2889            }))
2890            .await?
2891        {
2892            ServerResponse::Client(response) => Ok(response.client),
2893            other => Err(MuxError::protocol(format!(
2894                "unexpected response to client get: {other:?}"
2895            ))),
2896        }
2897    }
2898
2899    /// End the programs of buffers whose views a kill command just closed.
2900    /// Each is detached by then; one that is attached again by the time it is
2901    /// checked is shown somewhere else and keeps running, and one whose program
2902    /// already ended needs nothing. One left `Interrupted` (its keeper is gone)
2903    /// has no program, but is killed too so the server removes it.
2904    async fn kill_detached_buffers(&mut self, buffer_ids: Vec<BufferId>) -> Result<()> {
2905        for buffer_id in buffer_ids {
2906            let buffer = match self
2907                .request(ClientMessage::Buffer(BufferRequest::Get {
2908                    request_id: new_request_id(),
2909                    buffer_id,
2910                }))
2911                .await
2912            {
2913                Ok(ServerResponse::Buffer(response)) => response.buffer,
2914                Ok(_) => continue,
2915                // Gone already: a history view's buffer goes with its view.
2916                Err(error) if error.is_not_found() => continue,
2917                Err(error) => return Err(error),
2918            };
2919            if buffer.attachment_node_id.is_some()
2920                || !matches!(
2921                    buffer.state,
2922                    BufferRecordState::Running | BufferRecordState::Interrupted
2923                )
2924            {
2925                continue;
2926            }
2927            match self
2928                .request(ClientMessage::Buffer(BufferRequest::Kill {
2929                    request_id: new_request_id(),
2930                    buffer_id,
2931                    force: false,
2932                }))
2933                .await
2934            {
2935                Ok(_) => {}
2936                Err(error) if error.is_not_found() || error.is_conflict() => {}
2937                Err(error) => return Err(error),
2938            }
2939        }
2940        Ok(())
2941    }
2942
2943    async fn session_snapshot(&mut self, session_id: SessionId) -> Result<SessionSnapshot> {
2944        match self
2945            .request(ClientMessage::Session(SessionRequest::Get {
2946                request_id: new_request_id(),
2947                session_id,
2948            }))
2949            .await?
2950        {
2951            ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
2952            other => Err(MuxError::protocol(format!(
2953                "unexpected response to session get: {other:?}"
2954            ))),
2955        }
2956    }
2957
2958    async fn create_buffer(
2959        &mut self,
2960        title: Option<String>,
2961        command: Vec<String>,
2962        cwd: Option<String>,
2963        size: Option<PtySize>,
2964        session_id: SessionId,
2965    ) -> Result<BufferResponse> {
2966        match self
2967            .request(ClientMessage::Buffer(BufferRequest::Create {
2968                request_id: new_request_id(),
2969                title,
2970                command,
2971                cwd,
2972                env: Default::default(),
2973                size,
2974                session_id: Some(session_id),
2975            }))
2976            .await?
2977        {
2978            ServerResponse::Buffer(response) => Ok(response),
2979            other => Err(MuxError::protocol(format!(
2980                "unexpected response to buffer create: {other:?}"
2981            ))),
2982        }
2983    }
2984
2985    async fn rollback_created_buffer(&mut self, buffer_id: BufferId, operation: &str) {
2986        if let Err(error) = self
2987            .request(ClientMessage::Buffer(BufferRequest::Detach {
2988                request_id: new_request_id(),
2989                buffer_id,
2990            }))
2991            .await
2992        {
2993            warn!(
2994                %buffer_id,
2995                %error,
2996                operation,
2997                "failed to detach created buffer during rollback"
2998            );
2999        }
3000        if let Err(error) = self
3001            .request(ClientMessage::Buffer(BufferRequest::Kill {
3002                request_id: new_request_id(),
3003                buffer_id,
3004                force: true,
3005            }))
3006            .await
3007        {
3008            warn!(
3009                %buffer_id,
3010                %error,
3011                operation,
3012                "failed to kill created buffer during rollback"
3013            );
3014        }
3015    }
3016
3017    /// The session `-t` names, or the current one; see [`target`].
3018    async fn resolve_session_record(&mut self, target: Option<&str>) -> Result<SessionRecord> {
3019        self.target_session(target).await
3020    }
3021
3022    async fn resolve_session_snapshot(&mut self, target: Option<&str>) -> Result<SessionSnapshot> {
3023        let session = self.resolve_session_record(target).await?;
3024        self.session_snapshot(session.id).await
3025    }
3026
3027    /// The window `-t` names, or the current one; see [`target`].
3028    async fn resolve_window(&mut self, target: Option<&str>) -> Result<ResolvedWindow> {
3029        self.target_window(target).await
3030    }
3031
3032    /// The pane `-t` names, or the current one; see [`target`].
3033    async fn resolve_pane(&mut self, target: Option<&str>) -> Result<ResolvedPane> {
3034        self.target_pane(target).await
3035    }
3036
3037    async fn resolve_popup(&mut self, target: Option<&str>) -> Result<FloatingRecord> {
3038        match target {
3039            Some(target) => {
3040                let (session_target, selector) = split_scoped_required(target, "popup target")?;
3041                let popup_id = parse_floating_id(&selector)?;
3042                if let Some(session_target) = session_target {
3043                    let snapshot = self.resolve_session_snapshot(Some(&session_target)).await?;
3044                    floating_record(&snapshot, popup_id).cloned()
3045                } else {
3046                    let sessions = self.list_sessions().await?;
3047                    for session in sessions {
3048                        let snapshot = self.session_snapshot(session.id).await?;
3049                        if let Ok(popup) = floating_record(&snapshot, popup_id) {
3050                            return Ok(popup.clone());
3051                        }
3052                    }
3053                    Err(MuxError::not_found(format!(
3054                        "popup {popup_id} was not found"
3055                    )))
3056                }
3057            }
3058            None => {
3059                if let Some(BufferLocationAttachment::Floating {
3060                    session_id,
3061                    floating_id,
3062                    ..
3063                }) = self
3064                    .pane_location()
3065                    .await?
3066                    .map(|location| location.attachment)
3067                {
3068                    let snapshot = self.session_snapshot(session_id).await?;
3069                    return floating_record(&snapshot, floating_id).cloned();
3070                }
3071                let snapshot = self.resolve_session_snapshot(None).await?;
3072                let popup_id = snapshot
3073                    .session
3074                    .focused_floating_id
3075                    .ok_or_else(|| MuxError::not_found("session has no focused popup"))?;
3076                floating_record(&snapshot, popup_id).cloned()
3077            }
3078        }
3079    }
3080
3081    async fn find_session_containing_pane(&mut self, pane_id: NodeId) -> Result<SessionSnapshot> {
3082        let sessions = self.list_sessions().await?;
3083        for session in sessions {
3084            let snapshot = self.session_snapshot(session.id).await?;
3085            if node_record(&snapshot, pane_id).is_ok() {
3086                return Ok(snapshot);
3087            }
3088        }
3089
3090        Err(MuxError::not_found(format!("pane {pane_id} was not found")))
3091    }
3092}
3093
3094async fn rollback_created_buffer_on_error<T>(
3095    connection: &mut CliConnection,
3096    buffer_id: BufferId,
3097    operation: &str,
3098    result: Result<T>,
3099) -> Result<T> {
3100    match result {
3101        Ok(value) => Ok(value),
3102        Err(error) => {
3103            connection
3104                .rollback_created_buffer(buffer_id, operation)
3105                .await;
3106            Err(error)
3107        }
3108    }
3109}
3110
3111#[derive(Debug)]
3112struct ResolvedWindow {
3113    snapshot: SessionSnapshot,
3114    index: u32,
3115    child_id: NodeId,
3116}
3117
3118#[derive(Debug)]
3119struct ResolvedPane {
3120    snapshot: SessionSnapshot,
3121    leaf_id: NodeId,
3122    buffer_id: BufferId,
3123}
3124
3125/// The root window holding `node_id`: its index and the node it shows.
3126fn window_containing(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<(u32, NodeId)> {
3127    let (tabs_node, tabs) = root_tabs(snapshot)?;
3128    let mut child_id = node_id;
3129    loop {
3130        let parent_id = node_record(snapshot, child_id)?.parent_id.ok_or_else(|| {
3131            MuxError::not_found(format!("node {node_id} is not in a window of its session"))
3132        })?;
3133        if parent_id == tabs_node.id {
3134            break;
3135        }
3136        child_id = parent_id;
3137    }
3138    let index = tabs
3139        .tabs
3140        .iter()
3141        .position(|tab| tab.child_id == child_id)
3142        .ok_or_else(|| MuxError::protocol(format!("window of node {node_id} is not a root tab")))?;
3143    Ok((
3144        u32::try_from(index).expect("tab index fits into protocol width"),
3145        child_id,
3146    ))
3147}
3148
3149fn resolved_pane(snapshot: SessionSnapshot, pane_id: NodeId) -> Result<ResolvedPane> {
3150    let leaf = node_record(&snapshot, pane_id)?;
3151    let buffer_id = leaf
3152        .buffer_view
3153        .as_ref()
3154        .map(|view| view.buffer_id)
3155        .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane leaf")))?;
3156    Ok(ResolvedPane {
3157        snapshot,
3158        leaf_id: pane_id,
3159        buffer_id,
3160    })
3161}
3162
3163fn expect_session_snapshot(response: ServerResponse, operation: &str) -> Result<SessionSnapshot> {
3164    match response {
3165        ServerResponse::SessionSnapshot(response) => Ok(response.snapshot),
3166        other => Err(MuxError::protocol(format!(
3167            "unexpected response to {operation}: {other:?}"
3168        ))),
3169    }
3170}
3171
3172fn expect_floating(response: ServerResponse, operation: &str) -> Result<FloatingRecord> {
3173    match response {
3174        ServerResponse::Floating(FloatingResponse { floating, .. }) => Ok(floating),
3175        other => Err(MuxError::protocol(format!(
3176            "unexpected response to {operation}: {other:?}"
3177        ))),
3178    }
3179}
3180
3181fn expect_capture(response: ServerResponse, operation: &str) -> Result<SnapshotResponse> {
3182    match response {
3183        ServerResponse::Snapshot(snapshot) => Ok(snapshot),
3184        other => Err(MuxError::protocol(format!(
3185            "unexpected response to {operation}: {other:?}"
3186        ))),
3187    }
3188}
3189
3190fn expect_buffer_location(response: ServerResponse, operation: &str) -> Result<BufferLocation> {
3191    match response {
3192        ServerResponse::BufferLocation(BufferLocationResponse { location, .. }) => Ok(location),
3193        other => Err(MuxError::protocol(format!(
3194            "unexpected response to {operation}: {other:?}"
3195        ))),
3196    }
3197}
3198
3199fn expect_ok(response: ServerResponse, operation: &str) -> Result<()> {
3200    match response {
3201        ServerResponse::Ok(_) => Ok(()),
3202        other => Err(MuxError::protocol(format!(
3203            "unexpected response to {operation}: {other:?}"
3204        ))),
3205    }
3206}
3207
3208fn expect_buffer_with_location(
3209    response: ServerResponse,
3210    operation: &str,
3211) -> Result<(embers_protocol::BufferRecord, BufferLocation, bool)> {
3212    match response {
3213        ServerResponse::BufferWithLocation(response) => {
3214            let (_, buffer, location, at_root_tab) = response.into_parts();
3215            if buffer.id != location.buffer_id {
3216                return Err(MuxError::protocol(format!(
3217                    "{operation} returned buffer {} but location was for buffer {}",
3218                    buffer.id, location.buffer_id
3219                )));
3220            }
3221            Ok((buffer, location, at_root_tab))
3222        }
3223        other => Err(MuxError::protocol(format!(
3224            "unexpected response to {operation}: {other:?}"
3225        ))),
3226    }
3227}
3228
3229fn ensure_matching_buffer_id(
3230    operation: &str,
3231    requested_buffer_id: BufferId,
3232    actual_buffer_id: BufferId,
3233) -> Result<()> {
3234    if actual_buffer_id == requested_buffer_id {
3235        return Ok(());
3236    }
3237
3238    Err(MuxError::protocol(format!(
3239        "{operation} returned buffer {actual_buffer_id} for requested buffer {requested_buffer_id}"
3240    )))
3241}
3242
3243fn format_sessions(sessions: &[SessionRecord]) -> String {
3244    sessions
3245        .iter()
3246        .map(|session| format!("{}\t{}", session.id, session.name))
3247        .collect::<Vec<_>>()
3248        .join("\n")
3249}
3250
3251fn format_buffers(buffers: &[embers_protocol::BufferRecord]) -> String {
3252    buffers
3253        .iter()
3254        .map(|buffer| {
3255            let attachment = buffer
3256                .attachment_node_id
3257                .map(|node_id| format!("attached:{node_id}"))
3258                .unwrap_or_else(|| "detached".to_owned());
3259            format!(
3260                "{}\t{}\t{}\t{}",
3261                buffer.id,
3262                buffer_state_label(buffer.state),
3263                attachment,
3264                buffer.title
3265            )
3266        })
3267        .collect::<Vec<_>>()
3268        .join("\n")
3269}
3270
3271fn format_clients(clients: &[ClientRecord], sessions: &[SessionRecord]) -> String {
3272    clients
3273        .iter()
3274        .map(|client| {
3275            let current = client
3276                .current_session_id
3277                .map(|session_id| session_label(sessions, session_id))
3278                .unwrap_or_else(|| "-".to_owned());
3279            let scope = if client.subscribed_all_sessions {
3280                "all".to_owned()
3281            } else if client.subscribed_session_ids.is_empty() {
3282                "-".to_owned()
3283            } else {
3284                client
3285                    .subscribed_session_ids
3286                    .iter()
3287                    .map(|session_id| session_label(sessions, *session_id))
3288                    .collect::<Vec<_>>()
3289                    .join(",")
3290            };
3291            format!("{}\t{}\t{}", client.id, current, scope)
3292        })
3293        .collect::<Vec<_>>()
3294        .join("\n")
3295}
3296
3297fn format_buffer_details(
3298    buffer: &embers_protocol::BufferRecord,
3299    location: &BufferLocation,
3300) -> String {
3301    let mut lines = vec![
3302        format!("id\t{}", buffer.id),
3303        format!(
3304            "title\t{}",
3305            serde_json::to_string(&buffer.title).expect("buffer titles serialize to JSON")
3306        ),
3307        format!("state\t{}", buffer_state_label(buffer.state)),
3308        format!("kind\t{}", buffer_kind_label(buffer.kind)),
3309        format!("read_only\t{}", usize::from(buffer.read_only)),
3310        format!("location\t{}", format_buffer_location_inline(location)),
3311    ];
3312    if let Some(source_buffer_id) = buffer.helper_source_buffer_id {
3313        lines.push(format!("source_buffer\t{}", source_buffer_id));
3314    }
3315    if let Some(scope) = buffer.helper_scope {
3316        lines.push(format!("history_scope\t{}", history_scope_label(scope)));
3317    }
3318    if !buffer.command.is_empty() {
3319        let serialized_args =
3320            serde_json::to_string(&buffer.command).expect("buffer commands serialize to JSON");
3321        lines.push(format!("command\t{serialized_args}"));
3322    }
3323    // What runs in the foreground now, like tmux's `pane_current_command`,
3324    // and the job's whole command line when it is not the buffer's own.
3325    if let Some(name) = buffer.process_name() {
3326        let serialized_name =
3327            serde_json::to_string(&name).expect("program names serialize to JSON");
3328        lines.push(format!("current_command\t{serialized_name}"));
3329    }
3330    if let Some(foreground) = &buffer.foreground_command {
3331        let serialized_foreground =
3332            serde_json::to_string(foreground).expect("foreground commands serialize to JSON");
3333        lines.push(format!("foreground_command\t{serialized_foreground}"));
3334    }
3335    if let Some(cwd) = &buffer.cwd {
3336        let serialized_cwd =
3337            serde_json::to_string(cwd).expect("buffer working directories serialize to JSON");
3338        lines.push(format!("cwd\t{serialized_cwd}"));
3339    }
3340    if let Some(pipe) = &buffer.pipe {
3341        lines.push(format!(
3342            "pipe_state\t{}",
3343            buffer_pipe_state_label(pipe.state)
3344        ));
3345        let serialized_command =
3346            serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON");
3347        lines.push(format!("pipe_command\t{serialized_command}"));
3348        if let Some(pid) = pipe.pid {
3349            lines.push(format!("pipe_pid\t{pid}"));
3350        }
3351        if let Some(exit_code) = pipe.exit_code {
3352            lines.push(format!("pipe_exit_code\t{exit_code}"));
3353        }
3354        if let Some(reason) = pipe.stop_reason {
3355            lines.push(format!(
3356                "pipe_stop_reason\t{}",
3357                buffer_pipe_stop_reason_label(reason)
3358            ));
3359        }
3360    }
3361    lines.join("\n")
3362}
3363
3364fn format_buffer_pipe_details(
3365    buffer_id: BufferId,
3366    pipe: Option<&BufferPipeRecord>,
3367) -> Result<String> {
3368    let Some(pipe) = pipe else {
3369        return Err(MuxError::not_found(format!(
3370            "buffer {buffer_id} has no pipe state"
3371        )));
3372    };
3373    let mut lines = vec![
3374        format!("buffer_id\t{buffer_id}"),
3375        format!("state\t{}", buffer_pipe_state_label(pipe.state)),
3376        format!(
3377            "command\t{}",
3378            serde_json::to_string(&pipe.command).expect("buffer pipe commands serialize to JSON")
3379        ),
3380    ];
3381    if let Some(pid) = pipe.pid {
3382        lines.push(format!("pid\t{pid}"));
3383    }
3384    if let Some(exit_code) = pipe.exit_code {
3385        lines.push(format!("exit_code\t{exit_code}"));
3386    }
3387    if let Some(reason) = pipe.stop_reason {
3388        lines.push(format!(
3389            "stop_reason\t{}",
3390            buffer_pipe_stop_reason_label(reason)
3391        ));
3392    }
3393    Ok(lines.join("\n"))
3394}
3395
3396fn format_buffer_location_line(location: &BufferLocation) -> String {
3397    format!(
3398        "{}\t{}",
3399        location.buffer_id,
3400        format_buffer_location_value(location)
3401    )
3402}
3403
3404fn format_buffer_location_inline(location: &BufferLocation) -> String {
3405    match location.attachment {
3406        BufferLocationAttachment::Floating {
3407            session_id,
3408            node_id,
3409            floating_id,
3410        } => {
3411            format!("session:{session_id} node:{node_id} floating:{floating_id}")
3412        }
3413        BufferLocationAttachment::Session {
3414            session_id,
3415            node_id,
3416        } => format!("session:{session_id} node:{node_id}"),
3417        BufferLocationAttachment::Detached => "detached".to_owned(),
3418    }
3419}
3420
3421fn format_buffer_location_value(location: &BufferLocation) -> String {
3422    match location.attachment {
3423        BufferLocationAttachment::Floating {
3424            session_id,
3425            node_id,
3426            floating_id,
3427        } => {
3428            format!("session:{session_id}\tnode:{node_id}\tfloating:{floating_id}")
3429        }
3430        BufferLocationAttachment::Session {
3431            session_id,
3432            node_id,
3433        } => format!("session:{session_id}\tnode:{node_id}"),
3434        BufferLocationAttachment::Detached => "detached".to_owned(),
3435    }
3436}
3437
3438fn session_label(sessions: &[SessionRecord], session_id: SessionId) -> String {
3439    sessions
3440        .iter()
3441        .find(|session| session.id == session_id)
3442        .map(|session| format!("{}:{}", session.id, session.name))
3443        .unwrap_or_else(|| session_id.to_string())
3444}
3445
3446fn format_windows(snapshot: &SessionSnapshot, base_index: Option<u32>) -> Result<String> {
3447    let (_, tabs) = root_tabs(snapshot)?;
3448    Ok(tabs
3449        .tabs
3450        .iter()
3451        .enumerate()
3452        .map(|(index, tab)| {
3453            format!(
3454                "{}\t{}\t{}",
3455                window_number(index, base_index),
3456                usize::from(u32::try_from(index).ok() == Some(tabs.active)),
3457                tab.title
3458            )
3459        })
3460        .collect::<Vec<_>>()
3461        .join("\n"))
3462}
3463
3464fn format_panes(snapshot: &SessionSnapshot, pane_ids: &[NodeId]) -> Result<String> {
3465    pane_ids
3466        .iter()
3467        .map(|pane_id| {
3468            let leaf = node_record(snapshot, *pane_id)?;
3469            let buffer_id = leaf
3470                .buffer_view
3471                .as_ref()
3472                .map(|view| view.buffer_id)
3473                .ok_or_else(|| MuxError::invalid_input(format!("node {pane_id} is not a pane")))?;
3474            let buffer = buffer_record(snapshot, buffer_id)?;
3475            Ok(format!(
3476                "{}\t{}\t{}\t{}",
3477                pane_id,
3478                buffer.id,
3479                usize::from(snapshot.session.focused_leaf_id == Some(*pane_id)),
3480                buffer.title
3481            ))
3482        })
3483        .collect::<Result<Vec<_>>>()
3484        .map(|lines| lines.join("\n"))
3485}
3486
3487/// The pane showing `buffer_id` in `snapshot`, if any.
3488fn pane_showing(snapshot: &SessionSnapshot, buffer_id: BufferId) -> Option<NodeId> {
3489    snapshot
3490        .nodes
3491        .iter()
3492        .find(|node| {
3493            node.buffer_view
3494                .as_ref()
3495                .is_some_and(|view| view.buffer_id == buffer_id)
3496        })
3497        .map(|node| node.id)
3498}
3499
3500/// The nodes `snapshot`'s windows show, in window order.
3501fn window_children(snapshot: &SessionSnapshot) -> Result<Vec<NodeId>> {
3502    let (_, tabs) = root_tabs(snapshot)?;
3503    Ok(tabs.tabs.iter().map(|tab| tab.child_id).collect())
3504}
3505
3506/// Each window's active pane, as tmux window formats see it.
3507fn window_panes(snapshot: &SessionSnapshot) -> Result<Vec<Option<NodeId>>> {
3508    window_children(snapshot)?
3509        .into_iter()
3510        .map(|child_id| {
3511            let leaves = visible_leaf_ids(snapshot, child_id)?;
3512            Ok(window_active_pane(snapshot, child_id, &leaves))
3513        })
3514        .collect()
3515}
3516
3517/// The active pane of the window `child_id`, whose visible panes are
3518/// `leaves`: the session's focused pane when it is in the window, else the
3519/// pane the window would focus when selected, else its first pane.
3520fn window_active_pane(
3521    snapshot: &SessionSnapshot,
3522    child_id: NodeId,
3523    leaves: &[NodeId],
3524) -> Option<NodeId> {
3525    snapshot
3526        .session
3527        .focused_leaf_id
3528        .filter(|leaf_id| leaves.contains(leaf_id))
3529        .or_else(|| {
3530            node_record(snapshot, child_id)
3531                .ok()
3532                .and_then(|node| node.focus_leaf_id)
3533                .filter(|leaf_id| leaves.contains(leaf_id))
3534        })
3535        .or_else(|| leaves.first().copied())
3536}
3537
3538/// The index of the session's current window, `None` when it has none.
3539fn current_root_window(snapshot: &SessionSnapshot) -> Result<Option<u32>> {
3540    let (_, tabs) = root_tabs(snapshot)?;
3541    Ok((!tabs.tabs.is_empty()).then_some(tabs.active))
3542}
3543
3544/// Make root window `window` current again and give focus back to `leaf`, for
3545/// the `-d` of a command that made something else current. Focusing a pane of
3546/// a popup does not select a root window, so the window is selected first.
3547async fn restore_current(
3548    connection: &mut CliConnection,
3549    mut snapshot: SessionSnapshot,
3550    window: Option<u32>,
3551    leaf: Option<NodeId>,
3552    context: &str,
3553) -> Result<SessionSnapshot> {
3554    let session_id = snapshot.session.id;
3555    if let Some(index) = window {
3556        let response = connection
3557            .request(ClientMessage::Session(SessionRequest::SelectRootTab {
3558                request_id: new_request_id(),
3559                session_id,
3560                index,
3561            }))
3562            .await?;
3563        snapshot = expect_session_snapshot(response, context)?;
3564    }
3565    if let Some(leaf) = leaf.filter(|leaf| snapshot.session.focused_leaf_id != Some(*leaf)) {
3566        let response = connection
3567            .request(ClientMessage::Node(NodeRequest::Focus {
3568                request_id: new_request_id(),
3569                session_id,
3570                node_id: leaf,
3571            }))
3572            .await?;
3573        snapshot = expect_session_snapshot(response, context)?;
3574    }
3575    Ok(snapshot)
3576}
3577
3578fn active_root_window(snapshot: &SessionSnapshot) -> Result<(u32, String)> {
3579    let (_, tabs) = root_tabs(snapshot)?;
3580    let tab = protocol_tab(tabs, tabs.active)
3581        .ok_or_else(|| MuxError::protocol("session root tabs has invalid active index"))?;
3582    Ok((tabs.active, tab.title.clone()))
3583}
3584
3585/// Plan tmux `resize-pane -L/-R/-U/-D` for `pane`. The CLI has no viewport,
3586/// so the layout is projected at the size the session was last drawn at, which
3587/// the attached client's pane PTY sizes record, with the title rows it
3588/// reported.
3589fn plan_directional_resize(
3590    pane: &ResolvedPane,
3591    direction: NavigationDirection,
3592    cells: u16,
3593) -> Result<Option<SplitResize>> {
3594    let session_id = pane.snapshot.session.id;
3595    let mut state = ClientState::default();
3596    state.apply_session_snapshot(pane.snapshot.clone());
3597    let viewport = drawn_viewport(&state, session_id).ok_or_else(|| {
3598        MuxError::invalid_input("cannot determine the size the session is drawn at")
3599    })?;
3600    let presentation = PresentationModel::project_with(
3601        &state,
3602        session_id,
3603        viewport,
3604        PaneTitles::reported(&pane.snapshot.session),
3605    )?;
3606    Ok(presentation.resize_leaf(&state, pane.leaf_id, direction, cells))
3607}
3608
3609/// The session's root tabs, whose tabs are its windows.
3610fn root_tabs(
3611    snapshot: &SessionSnapshot,
3612) -> Result<(&embers_protocol::NodeRecord, &embers_protocol::TabsRecord)> {
3613    let node = node_record(snapshot, snapshot.session.root_node_id)?;
3614    let tabs = node.tabs.as_ref().ok_or_else(|| {
3615        MuxError::protocol(format!(
3616            "session {} root node {} is not tabs",
3617            snapshot.session.id, node.id
3618        ))
3619    })?;
3620    Ok((node, tabs))
3621}
3622
3623fn node_record(
3624    snapshot: &SessionSnapshot,
3625    node_id: NodeId,
3626) -> Result<&embers_protocol::NodeRecord> {
3627    snapshot
3628        .nodes
3629        .iter()
3630        .find(|node| node.id == node_id)
3631        .ok_or_else(|| MuxError::not_found(format!("node {node_id} is not present in snapshot")))
3632}
3633
3634fn buffer_record(
3635    snapshot: &SessionSnapshot,
3636    buffer_id: BufferId,
3637) -> Result<&embers_protocol::BufferRecord> {
3638    snapshot
3639        .buffers
3640        .iter()
3641        .find(|buffer| buffer.id == buffer_id)
3642        .ok_or_else(|| {
3643            MuxError::not_found(format!("buffer {buffer_id} is not present in snapshot"))
3644        })
3645}
3646
3647fn floating_record(snapshot: &SessionSnapshot, floating_id: FloatingId) -> Result<&FloatingRecord> {
3648    snapshot
3649        .floating
3650        .iter()
3651        .find(|floating| floating.id == floating_id)
3652        .ok_or_else(|| {
3653            MuxError::not_found(format!("popup {floating_id} is not present in snapshot"))
3654        })
3655}
3656
3657fn visible_leaf_ids(snapshot: &SessionSnapshot, node_id: NodeId) -> Result<Vec<NodeId>> {
3658    let node = node_record(snapshot, node_id)?;
3659    match node.kind {
3660        embers_protocol::NodeRecordKind::BufferView => Ok(vec![node.id]),
3661        embers_protocol::NodeRecordKind::Split => {
3662            let split = node
3663                .split
3664                .as_ref()
3665                .ok_or_else(|| MuxError::protocol(format!("split node {node_id} is malformed")))?;
3666            let mut leaves = Vec::new();
3667            for child_id in &split.child_ids {
3668                leaves.extend(visible_leaf_ids(snapshot, *child_id)?);
3669            }
3670            Ok(leaves)
3671        }
3672        embers_protocol::NodeRecordKind::Tabs => {
3673            let tabs = node
3674                .tabs
3675                .as_ref()
3676                .ok_or_else(|| MuxError::protocol(format!("tabs node {node_id} is malformed")))?;
3677            let active_child = protocol_tab(tabs, tabs.active).ok_or_else(|| {
3678                MuxError::protocol(format!("tabs node {node_id} has invalid active index"))
3679            })?;
3680            visible_leaf_ids(snapshot, active_child.child_id)
3681        }
3682    }
3683}
3684
3685fn resolve_window_index(
3686    tabs: &embers_protocol::TabsRecord,
3687    selector: Option<&str>,
3688    base_index: Option<u32>,
3689) -> Result<u32> {
3690    let Some(selector) = selector else {
3691        return Ok(tabs.active);
3692    };
3693
3694    if let Some(base_index) = base_index
3695        && let Ok(number) = selector.parse::<u64>()
3696    {
3697        return window_position(number, tabs.tabs.len(), base_index)
3698            .map(|position| u32::try_from(position).expect("tab index fits into protocol width"))
3699            .ok_or_else(|| {
3700                MuxError::not_found(format!("window index '{selector}' is out of range"))
3701            });
3702    }
3703
3704    if let Ok(index) = selector.parse::<u32>() {
3705        let mut candidates = Vec::new();
3706        if protocol_tab(tabs, index).is_some() {
3707            candidates.push(index);
3708        }
3709        if let Some(one_based) = index.checked_sub(1)
3710            && protocol_tab(tabs, one_based).is_some()
3711        {
3712            candidates.push(one_based);
3713        }
3714        candidates.sort_unstable();
3715        candidates.dedup();
3716        return match candidates.as_slice() {
3717            [only] => Ok(*only),
3718            [] => Err(MuxError::not_found(format!(
3719                "window index '{selector}' is out of range"
3720            ))),
3721            _ => Err(MuxError::invalid_input(format!(
3722                "window index '{selector}' is ambiguous between 0-based and 1-based addressing"
3723            ))),
3724        };
3725    }
3726
3727    let matches = tabs
3728        .tabs
3729        .iter()
3730        .enumerate()
3731        .filter(|(_, tab)| tab.title == selector)
3732        .map(|(index, _)| u32::try_from(index).expect("tab index fits into protocol width"))
3733        .collect::<Vec<_>>();
3734
3735    match matches.as_slice() {
3736        [index] => Ok(*index),
3737        [] => Err(MuxError::not_found(format!(
3738            "window '{selector}' was not found"
3739        ))),
3740        _ => Err(MuxError::conflict(format!(
3741            "window title '{selector}' matched multiple root tabs"
3742        ))),
3743    }
3744}
3745
3746fn protocol_tab(
3747    tabs: &embers_protocol::TabsRecord,
3748    index: u32,
3749) -> Option<&embers_protocol::TabRecord> {
3750    usize::try_from(index)
3751        .ok()
3752        .and_then(|index| tabs.tabs.get(index))
3753}
3754
3755fn split_scoped_target(target: Option<&str>) -> (Option<String>, Option<String>) {
3756    match target {
3757        Some(target) => {
3758            if let Some((session, selector)) = target.split_once(':') {
3759                (Some(session.to_owned()), Some(selector.to_owned()))
3760            } else {
3761                (None, Some(target.to_owned()))
3762            }
3763        }
3764        None => (None, None),
3765    }
3766}
3767
3768fn buffer_state_label(state: embers_protocol::BufferRecordState) -> &'static str {
3769    match state {
3770        embers_protocol::BufferRecordState::Created => "created",
3771        embers_protocol::BufferRecordState::Running => "running",
3772        embers_protocol::BufferRecordState::Interrupted => "interrupted",
3773        embers_protocol::BufferRecordState::Exited => "exited",
3774    }
3775}
3776
3777fn buffer_kind_label(kind: embers_protocol::BufferRecordKind) -> &'static str {
3778    match kind {
3779        embers_protocol::BufferRecordKind::Pty => "pty",
3780        embers_protocol::BufferRecordKind::Helper => "helper",
3781    }
3782}
3783
3784fn buffer_pipe_state_label(state: BufferPipeState) -> &'static str {
3785    match state {
3786        BufferPipeState::Running => "running",
3787        BufferPipeState::Stopped => "stopped",
3788    }
3789}
3790
3791fn buffer_pipe_stop_reason_label(reason: BufferPipeStopReason) -> &'static str {
3792    match reason {
3793        BufferPipeStopReason::Requested => "requested",
3794        BufferPipeStopReason::PipeExited => "pipe_exited",
3795        BufferPipeStopReason::WriteFailed => "write_failed",
3796        BufferPipeStopReason::BufferExited => "buffer_exited",
3797        BufferPipeStopReason::RuntimeInterrupted => "runtime_interrupted",
3798    }
3799}
3800
3801fn history_scope_label(scope: BufferHistoryScope) -> &'static str {
3802    match scope {
3803        BufferHistoryScope::Full => "full",
3804        BufferHistoryScope::Visible => "visible",
3805    }
3806}
3807
3808fn history_scope(scope: HistoryScopeArg) -> BufferHistoryScope {
3809    match scope {
3810        HistoryScopeArg::Full => BufferHistoryScope::Full,
3811        HistoryScopeArg::Visible => BufferHistoryScope::Visible,
3812    }
3813}
3814
3815fn history_placement(placement: HistoryPlacementArg) -> BufferHistoryPlacement {
3816    match placement {
3817        HistoryPlacementArg::Tab => BufferHistoryPlacement::Tab,
3818        HistoryPlacementArg::Floating => BufferHistoryPlacement::Floating,
3819    }
3820}
3821
3822fn ensure_history_attachment(
3823    buffer_id: BufferId,
3824    requested_placement: BufferHistoryPlacement,
3825    location: &BufferLocation,
3826) -> Result<()> {
3827    match (requested_placement, &location.attachment) {
3828        (BufferHistoryPlacement::Tab, BufferLocationAttachment::Session { .. })
3829        | (BufferHistoryPlacement::Floating, BufferLocationAttachment::Floating { .. }) => Ok(()),
3830        (_, BufferLocationAttachment::Detached) => Err(MuxError::protocol(format!(
3831            "buffer history returned detached helper location for buffer {buffer_id}"
3832        ))),
3833        (BufferHistoryPlacement::Tab, BufferLocationAttachment::Floating { .. }) => {
3834            Err(MuxError::protocol(format!(
3835                "buffer history returned unexpected attachment for buffer {buffer_id}: expected Tab"
3836            )))
3837        }
3838        (BufferHistoryPlacement::Floating, BufferLocationAttachment::Session { .. }) => {
3839            Err(MuxError::protocol(format!(
3840                "buffer history returned unexpected attachment for buffer {buffer_id}: expected Floating"
3841            )))
3842        }
3843    }
3844}
3845
3846fn ensure_history_response(
3847    source_buffer_id: BufferId,
3848    requested_scope: BufferHistoryScope,
3849    requested_placement: BufferHistoryPlacement,
3850    buffer: &embers_protocol::BufferRecord,
3851    location: &BufferLocation,
3852    at_root_tab: bool,
3853) -> Result<()> {
3854    if buffer.kind != embers_protocol::BufferRecordKind::Helper {
3855        return Err(MuxError::protocol(format!(
3856            "buffer history returned buffer {} with unexpected kind {:?}",
3857            buffer.id, buffer.kind
3858        )));
3859    }
3860    if buffer.helper_source_buffer_id != Some(source_buffer_id) {
3861        return Err(MuxError::protocol(format!(
3862            "buffer history returned helper {} for source {:?} instead of {source_buffer_id}",
3863            buffer.id, buffer.helper_source_buffer_id
3864        )));
3865    }
3866    if buffer.helper_scope != Some(requested_scope) {
3867        return Err(MuxError::protocol(format!(
3868            "buffer history returned helper {} with scope {:?} instead of {:?}",
3869            buffer.id, buffer.helper_scope, requested_scope
3870        )));
3871    }
3872
3873    let (session_id, node_id) = match &location.attachment {
3874        BufferLocationAttachment::Session {
3875            session_id,
3876            node_id,
3877        }
3878        | BufferLocationAttachment::Floating {
3879            session_id,
3880            node_id,
3881            ..
3882        } => (*session_id, *node_id),
3883        BufferLocationAttachment::Detached => {
3884            ensure_history_attachment(source_buffer_id, requested_placement, location)?;
3885            unreachable!("detached buffer history attachments should fail validation")
3886        }
3887    };
3888    if buffer.attachment_node_id != Some(node_id) {
3889        return Err(MuxError::protocol(format!(
3890            "buffer history returned helper {} attached to {:?} but location pointed at {node_id}",
3891            buffer.id, buffer.attachment_node_id
3892        )));
3893    }
3894
3895    ensure_history_attachment(source_buffer_id, requested_placement, location)?;
3896
3897    if matches!(requested_placement, BufferHistoryPlacement::Tab) && !at_root_tab {
3898        return Err(MuxError::protocol(format!(
3899            "buffer history returned helper node {node_id} outside session {session_id} root tabs"
3900        )));
3901    }
3902
3903    Ok(())
3904}
3905
3906fn break_destination(destination: BreakDestinationArg) -> NodeBreakDestination {
3907    match destination {
3908        BreakDestinationArg::Tab => NodeBreakDestination::Tab,
3909        BreakDestinationArg::Floating => NodeBreakDestination::Floating,
3910    }
3911}
3912
3913fn join_placement(placement: JoinPlacementArg) -> NodeJoinPlacement {
3914    match placement {
3915        JoinPlacementArg::Left => NodeJoinPlacement::Left,
3916        JoinPlacementArg::Right => NodeJoinPlacement::Right,
3917        JoinPlacementArg::Up => NodeJoinPlacement::Up,
3918        JoinPlacementArg::Down => NodeJoinPlacement::Down,
3919        JoinPlacementArg::TabBefore => NodeJoinPlacement::TabBefore,
3920        JoinPlacementArg::TabAfter => NodeJoinPlacement::TabAfter,
3921    }
3922}
3923
3924fn split_scoped_required(target: &str, label: &str) -> Result<(Option<String>, String)> {
3925    let (session, selector) = split_scoped_target(Some(target));
3926    let selector =
3927        selector.ok_or_else(|| MuxError::invalid_input(format!("{label} is required")))?;
3928    Ok((session, selector))
3929}
3930
3931fn parse_floating_id(raw: &str) -> Result<FloatingId> {
3932    raw.parse::<u64>().map(FloatingId).map_err(|_| {
3933        MuxError::invalid_input(format!("popup target '{raw}' is not a valid popup id"))
3934    })
3935}
3936
3937/// The size a pane created at `placement` in `snapshot`'s session will be
3938/// drawn at, from the viewport and title rows an attached client last
3939/// reported for the session. `None` (the server default) when no client ever
3940/// drew it.
3941fn spawn_size(snapshot: &SessionSnapshot, placement: PanePlacement) -> Option<PtySize> {
3942    let viewport = snapshot.session.viewport?;
3943    let pane_titles = PaneTitles::reported(&snapshot.session);
3944    let mut state = ClientState::default();
3945    state.apply_session_snapshot(snapshot.clone());
3946    spawn_pty_size(
3947        &state,
3948        snapshot.session.id,
3949        viewport,
3950        pane_titles,
3951        placement,
3952    )
3953}
3954
3955fn buffer_command(command: Vec<String>) -> Vec<String> {
3956    if command.is_empty() {
3957        vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())]
3958    } else {
3959        command
3960    }
3961}
3962
3963fn default_title(command: &[String], fallback: &str) -> String {
3964    command
3965        .first()
3966        .and_then(|value| {
3967            Path::new(value)
3968                .file_name()
3969                .and_then(|name| name.to_str())
3970                .map(str::to_owned)
3971        })
3972        .unwrap_or_else(|| fallback.to_owned())
3973}
3974
3975#[cfg(test)]
3976mod tests {
3977    #[cfg(windows)]
3978    use base64::Engine as _;
3979    use clap::{Parser, error::ErrorKind};
3980    use embers_core::{ActivityState, BufferId, FloatingId, NodeId, PtySize, SessionId};
3981    use embers_protocol::{
3982        BufferLocation, BufferRecord, BufferRecordKind, BufferRecordState, TabRecord, TabsRecord,
3983    };
3984    #[cfg(windows)]
3985    use std::ffi::OsString;
3986    #[cfg(unix)]
3987    use std::ffi::OsString;
3988    #[cfg(unix)]
3989    use std::os::unix::ffi::OsStringExt;
3990    #[cfg(windows)]
3991    use std::os::windows::ffi::OsStringExt;
3992    use std::path::Path;
3993
3994    use super::{
3995        BreakDestinationArg, BufferCommand, Cli, Command, HistoryPlacementArg, HistoryScopeArg,
3996        JoinPlacementArg, NodeCommand, ensure_history_attachment, format_buffer_details,
3997        pane_buffer, resolve_window_index, split_scoped_required, split_scoped_target,
3998    };
3999
4000    #[test]
4001    fn pane_buffer_is_only_taken_for_the_panes_own_server() {
4002        let socket = Path::new("/run/embers/embers.sock");
4003        let same = Some(std::ffi::OsStr::new("/run/embers/embers.sock"));
4004        let other = Some(std::ffi::OsStr::new("/run/embers/other.sock"));
4005        assert_eq!(pane_buffer(Some("7"), same, socket), Some(BufferId(7)));
4006        assert_eq!(pane_buffer(Some("7"), other, socket), None);
4007        assert_eq!(pane_buffer(Some("7"), None, socket), None);
4008        assert_eq!(pane_buffer(None, same, socket), None);
4009        assert_eq!(pane_buffer(Some("%7"), same, socket), None);
4010    }
4011
4012    #[test]
4013    fn parser_accepts_global_socket_after_subcommand() {
4014        let cli = Cli::try_parse_from([
4015            "embers",
4016            "new-window",
4017            "--socket",
4018            "/tmp/mux.sock",
4019            "--title",
4020            "logs",
4021            "--",
4022            "/bin/sh",
4023        ])
4024        .expect("cli parses");
4025
4026        match cli.command {
4027            Some(super::Command::NewWindow { title, command, .. }) => {
4028                assert_eq!(title.as_deref(), Some("logs"));
4029                assert_eq!(command, vec!["/bin/sh"]);
4030            }
4031            other => panic!("expected new-window command, got {other:?}"),
4032        }
4033    }
4034
4035    #[test]
4036    fn runtime_keeper_uses_distinct_keeper_socket_flag() {
4037        let cli = Cli::try_parse_from([
4038            "embers",
4039            "__runtime-keeper",
4040            "--socket",
4041            "/tmp/global.sock",
4042            "--keeper-socket",
4043            "/tmp/keeper.sock",
4044            "--cols",
4045            "80",
4046            "--rows",
4047            "24",
4048            "--",
4049            "/bin/sh",
4050        ])
4051        .expect("cli parses");
4052
4053        assert_eq!(cli.socket.as_deref(), Some(Path::new("/tmp/global.sock")));
4054        match cli.command {
4055            Some(super::Command::RuntimeKeeper {
4056                keeper_socket,
4057                cols,
4058                rows,
4059                command,
4060                ..
4061            }) => {
4062                assert_eq!(keeper_socket, Path::new("/tmp/keeper.sock"));
4063                assert_eq!((cols, rows), (80, 24));
4064                assert_eq!(command, vec!["/bin/sh"]);
4065            }
4066            other => panic!("expected runtime keeper command, got {other:?}"),
4067        }
4068    }
4069
4070    #[cfg(unix)]
4071    #[test]
4072    fn runtime_keeper_env_values_decode_base64_losslessly() {
4073        let (key, value) = super::parse_env_arg("KEY=base64:AP8=").expect("env parses");
4074        assert_eq!(key, "KEY");
4075        assert_eq!(value, OsString::from_vec(vec![0, 255]));
4076    }
4077
4078    #[cfg(windows)]
4079    #[test]
4080    fn runtime_keeper_env_values_decode_utf16le_losslessly() {
4081        let encoded = base64::engine::general_purpose::STANDARD.encode([0x00, 0xD8, 0x61, 0x00]);
4082        let (key, value) =
4083            super::parse_env_arg(&format!("KEY=base64:{encoded}")).expect("env parses");
4084        assert_eq!(key, "KEY");
4085        assert_eq!(value, OsString::from_wide(&[0xD800, 0x0061]));
4086    }
4087
4088    #[test]
4089    fn scoped_targets_split_session_prefix() {
4090        assert_eq!(
4091            split_scoped_required("main:2", "window target").expect("target parses"),
4092            (Some("main".to_owned()), "2".to_owned())
4093        );
4094        assert_eq!(split_scoped_target(Some("3")), (None, Some("3".to_owned())));
4095    }
4096
4097    #[test]
4098    fn numeric_window_indices_report_ambiguity() {
4099        let tabs = TabsRecord {
4100            active: 0,
4101            tabs: vec![
4102                TabRecord {
4103                    title: "one".to_owned(),
4104                    child_id: NodeId(1),
4105                    synchronize_panes: false,
4106                },
4107                TabRecord {
4108                    title: "two".to_owned(),
4109                    child_id: NodeId(2),
4110                    synchronize_panes: false,
4111                },
4112                TabRecord {
4113                    title: "three".to_owned(),
4114                    child_id: NodeId(3),
4115                    synchronize_panes: false,
4116                },
4117            ],
4118        };
4119
4120        let error = resolve_window_index(&tabs, Some("1"), None).expect_err("index is ambiguous");
4121        assert!(
4122            error
4123                .to_string()
4124                .contains("ambiguous between 0-based and 1-based")
4125        );
4126        assert_eq!(
4127            resolve_window_index(&tabs, Some("0"), None).expect("zero resolves"),
4128            0
4129        );
4130    }
4131
4132    #[test]
4133    fn numeric_window_indices_count_from_a_set_base_index() {
4134        let tabs = TabsRecord {
4135            active: 0,
4136            tabs: ["one", "two"]
4137                .into_iter()
4138                .enumerate()
4139                .map(|(index, title)| TabRecord {
4140                    title: title.to_owned(),
4141                    child_id: NodeId(index as u64 + 1),
4142                    synchronize_panes: false,
4143                })
4144                .collect(),
4145        };
4146
4147        assert_eq!(resolve_window_index(&tabs, Some("1"), Some(1)).unwrap(), 0);
4148        assert_eq!(resolve_window_index(&tabs, Some("2"), Some(1)).unwrap(), 1);
4149        assert!(resolve_window_index(&tabs, Some("0"), Some(1)).is_err());
4150        assert_eq!(resolve_window_index(&tabs, Some("1"), Some(0)).unwrap(), 1);
4151        assert_eq!(
4152            resolve_window_index(&tabs, Some("two"), Some(1)).unwrap(),
4153            1
4154        );
4155    }
4156
4157    #[test]
4158    fn buffer_details_location_row_uses_single_tab_delimiter() {
4159        let details = format_buffer_details(
4160            &BufferRecord {
4161                id: BufferId(7),
4162                title: "logs".to_owned(),
4163                command: vec!["/bin/sh".to_owned()],
4164                cwd: Some("/tmp".to_owned()),
4165                kind: BufferRecordKind::Pty,
4166                pid: None,
4167                env: Default::default(),
4168                state: BufferRecordState::Running,
4169                attachment_node_id: Some(NodeId(3)),
4170                read_only: false,
4171                helper_source_buffer_id: None,
4172                helper_scope: None,
4173                pty_size: PtySize::new(80, 24),
4174                activity: ActivityState::Idle,
4175                last_snapshot_seq: 0,
4176                exit_code: None,
4177                pipe: None,
4178                user_options: Default::default(),
4179                foreground_command: None,
4180            },
4181            &BufferLocation::session(BufferId(7), SessionId(1), NodeId(3)),
4182        );
4183
4184        let location_line = details
4185            .lines()
4186            .find(|line| line.starts_with("location\t"))
4187            .expect("location row present");
4188        assert_eq!(location_line.matches('\t').count(), 1);
4189    }
4190
4191    #[test]
4192    fn buffer_details_command_row_preserves_argument_boundaries() {
4193        let details = format_buffer_details(
4194            &BufferRecord {
4195                id: BufferId(8),
4196                title: "script".to_owned(),
4197                command: vec![
4198                    "/bin/sh".to_owned(),
4199                    "-lc".to_owned(),
4200                    "printf '%s\\n' 'hello world'".to_owned(),
4201                ],
4202                cwd: None,
4203                kind: BufferRecordKind::Pty,
4204                pid: None,
4205                env: Default::default(),
4206                state: BufferRecordState::Running,
4207                attachment_node_id: Some(NodeId(4)),
4208                read_only: false,
4209                helper_source_buffer_id: None,
4210                helper_scope: None,
4211                pty_size: PtySize::new(80, 24),
4212                activity: ActivityState::Idle,
4213                last_snapshot_seq: 0,
4214                exit_code: None,
4215                pipe: None,
4216                user_options: Default::default(),
4217                foreground_command: None,
4218            },
4219            &BufferLocation::session(BufferId(8), SessionId(1), NodeId(4)),
4220        );
4221
4222        let command_line = details
4223            .lines()
4224            .find(|line| line.starts_with("command\t"))
4225            .expect("command row present");
4226        let serialized_args = command_line
4227            .strip_prefix("command\t")
4228            .expect("command row prefix present");
4229        let command: Vec<String> =
4230            serde_json::from_str(serialized_args).expect("command row uses JSON");
4231        assert_eq!(
4232            command,
4233            vec![
4234                "/bin/sh".to_owned(),
4235                "-lc".to_owned(),
4236                "printf '%s\\n' 'hello world'".to_owned(),
4237            ]
4238        );
4239    }
4240
4241    #[test]
4242    fn buffer_details_show_the_foreground_command() {
4243        let buffer = |foreground_command: Option<Vec<String>>| BufferRecord {
4244            id: BufferId(10),
4245            title: "shell".to_owned(),
4246            command: vec!["/bin/zsh".to_owned()],
4247            cwd: None,
4248            kind: BufferRecordKind::Pty,
4249            pid: None,
4250            env: Default::default(),
4251            state: BufferRecordState::Running,
4252            attachment_node_id: Some(NodeId(6)),
4253            read_only: false,
4254            helper_source_buffer_id: None,
4255            helper_scope: None,
4256            pty_size: PtySize::new(80, 24),
4257            activity: ActivityState::Idle,
4258            last_snapshot_seq: 0,
4259            exit_code: None,
4260            pipe: None,
4261            user_options: Default::default(),
4262            foreground_command,
4263        };
4264        let location = BufferLocation::session(BufferId(10), SessionId(1), NodeId(6));
4265        let row = |details: &str, name: &str| {
4266            details
4267                .lines()
4268                .find_map(|line| line.strip_prefix(&format!("{name}\t")))
4269                .map(str::to_owned)
4270        };
4271
4272        // The shell runs Neovim as its foreground job.
4273        let details = format_buffer_details(
4274            &buffer(Some(vec!["nvim".to_owned(), "notes.txt".to_owned()])),
4275            &location,
4276        );
4277        assert_eq!(
4278            row(&details, "current_command").as_deref(),
4279            Some("\"nvim\"")
4280        );
4281        let foreground: Vec<String> = serde_json::from_str(
4282            &row(&details, "foreground_command").expect("foreground_command row present"),
4283        )
4284        .expect("foreground_command row uses JSON");
4285        assert_eq!(foreground, ["nvim", "notes.txt"]);
4286
4287        // The shell itself is in the foreground.
4288        let details = format_buffer_details(&buffer(None), &location);
4289        assert_eq!(row(&details, "current_command").as_deref(), Some("\"zsh\""));
4290        assert_eq!(row(&details, "foreground_command"), None);
4291    }
4292
4293    #[test]
4294    fn buffer_details_title_and_cwd_rows_use_json() {
4295        let details = format_buffer_details(
4296            &BufferRecord {
4297                id: BufferId(9),
4298                title: "build\tlogs\nstderr".to_owned(),
4299                command: Vec::new(),
4300                cwd: Some("/tmp/work\tspace\nhere".to_owned()),
4301                kind: BufferRecordKind::Pty,
4302                pid: None,
4303                env: Default::default(),
4304                state: BufferRecordState::Running,
4305                attachment_node_id: Some(NodeId(5)),
4306                read_only: false,
4307                helper_source_buffer_id: None,
4308                helper_scope: None,
4309                pty_size: PtySize::new(80, 24),
4310                activity: ActivityState::Idle,
4311                last_snapshot_seq: 0,
4312                exit_code: None,
4313                pipe: None,
4314                user_options: Default::default(),
4315                foreground_command: None,
4316            },
4317            &BufferLocation::session(BufferId(9), SessionId(1), NodeId(5)),
4318        );
4319
4320        let title_line = details
4321            .lines()
4322            .find(|line| line.starts_with("title\t"))
4323            .expect("title row present");
4324        let cwd_line = details
4325            .lines()
4326            .find(|line| line.starts_with("cwd\t"))
4327            .expect("cwd row present");
4328
4329        let title = title_line
4330            .strip_prefix("title\t")
4331            .expect("title row prefix present");
4332        let cwd = cwd_line
4333            .strip_prefix("cwd\t")
4334            .expect("cwd row prefix present");
4335
4336        assert_eq!(
4337            serde_json::from_str::<String>(title).expect("title row uses JSON"),
4338            "build\tlogs\nstderr"
4339        );
4340        assert_eq!(
4341            serde_json::from_str::<String>(cwd).expect("cwd row uses JSON"),
4342            "/tmp/work\tspace\nhere"
4343        );
4344    }
4345
4346    #[test]
4347    fn buffer_subcommands_parse_expected_flags_and_defaults() {
4348        let history =
4349            Cli::try_parse_from(["embers", "buffer", "history", "7"]).expect("history parses");
4350        match history.command {
4351            Some(Command::Buffer {
4352                command:
4353                    BufferCommand::History {
4354                        buffer_id,
4355                        scope,
4356                        placement,
4357                        client,
4358                    },
4359            }) => {
4360                assert_eq!(buffer_id, 7);
4361                assert!(matches!(scope, HistoryScopeArg::Full));
4362                assert!(matches!(placement, HistoryPlacementArg::Tab));
4363                assert_eq!(client, None);
4364            }
4365            other => panic!("expected buffer history command, got {other:?}"),
4366        }
4367
4368        let history_with_flags = Cli::try_parse_from([
4369            "embers",
4370            "buffer",
4371            "history",
4372            "9",
4373            "--scope",
4374            "visible",
4375            "--placement",
4376            "floating",
4377            "--client",
4378            "5",
4379        ])
4380        .expect("history flags parse");
4381        match history_with_flags.command {
4382            Some(Command::Buffer {
4383                command:
4384                    BufferCommand::History {
4385                        buffer_id,
4386                        scope,
4387                        placement,
4388                        client,
4389                    },
4390            }) => {
4391                assert_eq!(buffer_id, 9);
4392                assert!(matches!(scope, HistoryScopeArg::Visible));
4393                assert!(matches!(placement, HistoryPlacementArg::Floating));
4394                assert_eq!(client.map(std::num::NonZeroU64::get), Some(5));
4395            }
4396            other => panic!("expected flagged buffer history command, got {other:?}"),
4397        }
4398
4399        let reveal = Cli::try_parse_from(["embers", "buffer", "reveal", "11", "--client", "6"])
4400            .expect("reveal parses");
4401        match reveal.command {
4402            Some(Command::Buffer {
4403                command: BufferCommand::Reveal { buffer_id, client },
4404            }) => {
4405                assert_eq!(buffer_id, 11);
4406                assert_eq!(client.map(std::num::NonZeroU64::get), Some(6));
4407            }
4408            other => panic!("expected buffer reveal command, got {other:?}"),
4409        }
4410
4411        let set_option =
4412            Cli::try_parse_from(["embers", "buffer", "set-option", "-t", "p1", "is-vim", "1"])
4413                .expect("set-option parses");
4414        match set_option.command {
4415            Some(Command::Buffer {
4416                command:
4417                    BufferCommand::SetOption {
4418                        target,
4419                        key,
4420                        value,
4421                        unset,
4422                    },
4423            }) => {
4424                assert_eq!(target.as_deref(), Some("p1"));
4425                assert_eq!(key, "is-vim");
4426                assert_eq!(value.as_deref(), Some("1"));
4427                assert!(!unset);
4428            }
4429            other => panic!("expected buffer set-option command, got {other:?}"),
4430        }
4431
4432        let unset = Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim"])
4433            .expect("set-option --unset parses");
4434        match unset.command {
4435            Some(Command::Buffer {
4436                command:
4437                    BufferCommand::SetOption {
4438                        target,
4439                        key,
4440                        value,
4441                        unset,
4442                    },
4443            }) => {
4444                assert_eq!(target, None);
4445                assert_eq!(key, "is-vim");
4446                assert_eq!(value, None);
4447                assert!(unset);
4448            }
4449            other => panic!("expected buffer set-option --unset command, got {other:?}"),
4450        }
4451
4452        // A value with no --unset parses (the missing-value case is rejected at
4453        // runtime, not at the parser); --unset combined with a value conflicts.
4454        let no_value = Cli::try_parse_from(["embers", "buffer", "set-option", "is-vim"])
4455            .expect("set-option without a value still parses");
4456        assert!(matches!(
4457            no_value.command,
4458            Some(Command::Buffer {
4459                command: BufferCommand::SetOption {
4460                    value: None,
4461                    unset: false,
4462                    ..
4463                },
4464            })
4465        ));
4466        assert!(
4467            Cli::try_parse_from(["embers", "buffer", "set-option", "--unset", "is-vim", "1"])
4468                .is_err(),
4469            "--unset combined with a value should be rejected"
4470        );
4471
4472        let show_options = Cli::try_parse_from(["embers", "buffer", "show-options", "-t", "p2"])
4473            .expect("show-options parses");
4474        match show_options.command {
4475            Some(Command::Buffer {
4476                command: BufferCommand::ShowOptions { target },
4477            }) => assert_eq!(target.as_deref(), Some("p2")),
4478            other => panic!("expected buffer show-options command, got {other:?}"),
4479        }
4480        let show_options_default = Cli::try_parse_from(["embers", "buffer", "show-options"])
4481            .expect("show-options without a target parses");
4482        assert!(matches!(
4483            show_options_default.command,
4484            Some(Command::Buffer {
4485                command: BufferCommand::ShowOptions { target: None },
4486            })
4487        ));
4488    }
4489
4490    #[test]
4491    fn history_attachment_validation_accepts_matching_locations() {
4492        assert!(
4493            ensure_history_attachment(
4494                BufferId(7),
4495                embers_protocol::BufferHistoryPlacement::Tab,
4496                &BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
4497            )
4498            .is_ok()
4499        );
4500        assert!(
4501            ensure_history_attachment(
4502                BufferId(7),
4503                embers_protocol::BufferHistoryPlacement::Floating,
4504                &BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
4505            )
4506            .is_ok()
4507        );
4508    }
4509
4510    #[test]
4511    fn history_attachment_validation_rejects_mismatched_locations() {
4512        let floating_error = ensure_history_attachment(
4513            BufferId(7),
4514            embers_protocol::BufferHistoryPlacement::Floating,
4515            &BufferLocation::session(BufferId(70), SessionId(1), NodeId(3)),
4516        )
4517        .expect_err("floating history should reject tab helper");
4518        assert!(floating_error.to_string().contains("expected Floating"));
4519
4520        let tab_error = ensure_history_attachment(
4521            BufferId(7),
4522            embers_protocol::BufferHistoryPlacement::Tab,
4523            &BufferLocation::floating(BufferId(71), SessionId(1), NodeId(4), FloatingId(5)),
4524        )
4525        .expect_err("tab history should reject floating helper");
4526        assert!(tab_error.to_string().contains("expected Tab"));
4527    }
4528
4529    #[test]
4530    fn node_subcommands_parse_expected_flags_and_reject_legacy_shortcuts() {
4531        let break_to_floating =
4532            Cli::try_parse_from(["embers", "node", "break", "11", "--to", "floating"])
4533                .expect("break parses");
4534        match break_to_floating.command {
4535            Some(Command::Node {
4536                command:
4537                    NodeCommand::Break {
4538                        node_id,
4539                        destination,
4540                    },
4541            }) => {
4542                assert_eq!(node_id, 11);
4543                assert!(matches!(destination, BreakDestinationArg::Floating));
4544            }
4545            other => panic!("expected node break command, got {other:?}"),
4546        }
4547
4548        let join_after = Cli::try_parse_from([
4549            "embers",
4550            "node",
4551            "join-buffer",
4552            "21",
4553            "34",
4554            "--as",
4555            "tab-after",
4556        ])
4557        .expect("join-buffer tab-after parses");
4558        match join_after.command {
4559            Some(Command::Node {
4560                command:
4561                    NodeCommand::JoinBuffer {
4562                        node_id,
4563                        buffer_id,
4564                        placement,
4565                    },
4566            }) => {
4567                assert_eq!(node_id, 21);
4568                assert_eq!(buffer_id, 34);
4569                assert!(matches!(placement, JoinPlacementArg::TabAfter));
4570            }
4571            other => panic!("expected node join-buffer command, got {other:?}"),
4572        }
4573
4574        let join_default = Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34"])
4575            .expect("join-buffer default parses");
4576        match join_default.command {
4577            Some(Command::Node {
4578                command: NodeCommand::JoinBuffer { placement, .. },
4579            }) => {
4580                assert!(matches!(placement, JoinPlacementArg::TabAfter));
4581            }
4582            other => panic!("expected default node join-buffer command, got {other:?}"),
4583        }
4584
4585        let join_before = Cli::try_parse_from([
4586            "embers",
4587            "node",
4588            "join-buffer",
4589            "21",
4590            "34",
4591            "--as",
4592            "tab-before",
4593        ])
4594        .expect("join-buffer tab-before parses");
4595        match join_before.command {
4596            Some(Command::Node {
4597                command: NodeCommand::JoinBuffer { placement, .. },
4598            }) => {
4599                assert!(matches!(placement, JoinPlacementArg::TabBefore));
4600            }
4601            other => panic!("expected node join-buffer command, got {other:?}"),
4602        }
4603
4604        let legacy_tab_after =
4605            Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-after"])
4606                .expect_err("legacy --tab-after should be rejected");
4607        assert_eq!(legacy_tab_after.kind(), ErrorKind::UnknownArgument);
4608
4609        let legacy_tab_before =
4610            Cli::try_parse_from(["embers", "node", "join-buffer", "21", "34", "--tab-before"])
4611                .expect_err("legacy --tab-before should be rejected");
4612        assert_eq!(legacy_tab_before.kind(), ErrorKind::UnknownArgument);
4613    }
4614}