Skip to main content

embers_cli/
lib.rs

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