Skip to main content

aimux_server/
session.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicU32, Ordering};
3use std::sync::{Arc, Weak};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{bail, Context, Result};
7use tokio::sync::{broadcast, RwLock};
8use aimux_common::config::AimuxConfig;
9use aimux_protocol::{
10    PaneId, PaneInfo, ProcessInfo, SessionId, SessionInfo, WindowId, WindowInfo,
11};
12
13use crate::screen::{Screen, DEFAULT_SCROLLBACK_LIMIT};
14
15const MIN_PANE_COLS: u16 = 2;
16const MIN_PANE_ROWS: u16 = 1;
17const MAX_PANE_COLS: u16 = 1000;
18const MAX_PANE_ROWS: u16 = 1000;
19
20// ---------------------------------------------------------------------------
21// PtyBackend trait
22// ---------------------------------------------------------------------------
23
24pub trait PtyBackend: Send {
25    fn write(&self, data: &[u8]) -> Result<()>;
26    fn resize(&self, cols: u16, rows: u16) -> Result<()>;
27    fn close(&mut self) -> Result<()>;
28    fn pid(&self) -> u32;
29}
30
31/// Pane IDs + extracted PTY backends that the caller must close outside the lock.
32pub type LayoutResult = Result<(Vec<PaneId>, Vec<Box<dyn PtyBackend>>)>;
33
34// ---------------------------------------------------------------------------
35// PtySpawnResult - returned by the factory
36// ---------------------------------------------------------------------------
37
38pub struct PtySpawnResult {
39    pub backend: Box<dyn PtyBackend>,
40    pub pid: u32,
41    pub output_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
42    pub exit_rx: tokio::sync::oneshot::Receiver<i32>,
43}
44
45/// Factory function type for creating PTY backends. Allows injection of MockPty in tests.
46pub type PtyFactory = Box<dyn Fn(&str, u16, u16) -> Result<PtySpawnResult> + Send + Sync>;
47
48// ---------------------------------------------------------------------------
49// Pane
50// ---------------------------------------------------------------------------
51
52#[derive(Default)]
53pub struct PaneOptions {
54    pub remain_on_exit: bool,
55}
56
57pub struct Pane {
58    pub id: PaneId,
59    pub pty: Box<dyn PtyBackend>,
60    pub screen: Arc<RwLock<Screen>>,
61    pub process: ProcessInfo,
62    pub size: (u16, u16), // (cols, rows)
63    pub title: String,
64    pub shell_command: String,
65    pub options: PaneOptions,
66    /// Notifies waiters when screen content is updated.
67    pub update_tx: broadcast::Sender<()>,
68    /// Forwards raw PTY output bytes for subscribers.
69    pub raw_tx: broadcast::Sender<Vec<u8>>,
70    /// Sends exit code when the process dies.
71    pub exit_tx: broadcast::Sender<i32>,
72}
73
74impl Pane {
75    pub fn to_info(&self) -> PaneInfo {
76        PaneInfo {
77            id: self.id.clone(),
78            pid: self.process.pid,
79            running: self.process.running,
80            exit_code: self.process.exit_code,
81            size: self.size,
82            title: self.title.clone(),
83            marks: Vec::new(),
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Layout
90// ---------------------------------------------------------------------------
91
92#[derive(Debug, Clone, PartialEq)]
93pub enum Layout {
94    Single,
95    EvenHorizontal,
96    EvenVertical,
97    MainVertical,
98    Tiled,
99    Custom(crate::layout_dsl::LayoutTree),
100}
101
102// ---------------------------------------------------------------------------
103// Window
104// ---------------------------------------------------------------------------
105
106pub struct Window {
107    pub id: WindowId,
108    pub panes: Vec<Pane>,
109    pub active_pane: usize,
110    pub layout: Layout,
111}
112
113impl Window {
114    pub fn split_pane(&mut self, pane: Pane, horizontal: bool) {
115        self.panes.push(pane);
116        // Custom layout invalidated when panes added outside apply_layout
117        if matches!(self.layout, Layout::Custom(_)) {
118            self.layout = Layout::EvenHorizontal;
119        } else if self.panes.len() == 2 {
120            self.layout = if horizontal {
121                Layout::EvenHorizontal
122            } else {
123                Layout::EvenVertical
124            };
125        }
126    }
127
128    pub fn compute_pane_positions(
129        &self,
130        area_width: u16,
131        area_height: u16,
132    ) -> Vec<(PaneId, crate::layout::PanePosition)> {
133        let positions =
134            crate::layout::compute_layout(&self.layout, self.panes.len(), area_width, area_height);
135        self.panes
136            .iter()
137            .zip(positions)
138            .map(|(pane, pos)| (pane.id.clone(), pos))
139            .collect()
140    }
141
142    /// Remove a pane by ID. Returns (window_empty, backend) so the
143    /// caller can close the backend outside the session manager lock.
144    pub fn kill_pane(&mut self, pane_id: &str) -> Result<(bool, Box<dyn PtyBackend>)> {
145        let idx = self
146            .panes
147            .iter()
148            .position(|p| p.id == pane_id)
149            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
150
151        let pane = self.panes.remove(idx);
152        let backend = pane.pty;
153
154        // Adjust active_pane index.
155        if self.panes.is_empty() {
156            return Ok((true, backend));
157        }
158        if self.active_pane >= self.panes.len() {
159            self.active_pane = self.panes.len() - 1;
160        }
161        if self.panes.len() == 1 {
162            self.layout = Layout::Single;
163        } else if matches!(self.layout, Layout::Custom(_)) {
164            // Custom layout invalidated when panes removed outside apply_layout
165            self.layout = Layout::EvenHorizontal;
166        }
167        Ok((false, backend))
168    }
169
170    pub fn to_info(&self) -> WindowInfo {
171        let active_pane_id = self
172            .panes
173            .get(self.active_pane)
174            .map(|p| p.id.clone())
175            .unwrap_or_default();
176        WindowInfo {
177            id: self.id,
178            panes: self.panes.iter().map(|p| p.to_info()).collect(),
179            active_pane: active_pane_id,
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// SessionOptions
186// ---------------------------------------------------------------------------
187
188pub struct SessionOptions {
189    pub default_shell: String,
190    pub prompt_pattern: String,
191    pub scrollback_limit: usize,
192}
193
194fn default_shell() -> String {
195    #[cfg(windows)]
196    {
197        "powershell.exe".to_string()
198    }
199    #[cfg(unix)]
200    {
201        std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
202    }
203}
204
205impl Default for SessionOptions {
206    fn default() -> Self {
207        Self {
208            default_shell: default_shell(),
209            prompt_pattern: ">".to_string(),
210            scrollback_limit: DEFAULT_SCROLLBACK_LIMIT,
211        }
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Session
217// ---------------------------------------------------------------------------
218
219pub struct Session {
220    pub name: SessionId,
221    pub windows: Vec<Window>,
222    pub active_window: usize,
223    pub options: SessionOptions,
224    pub created_at: SystemTime,
225    next_window_id: u32,
226}
227
228impl Session {
229    fn new(name: &str) -> Self {
230        Self {
231            name: name.to_string(),
232            windows: Vec::new(),
233            active_window: 0,
234            options: SessionOptions::default(),
235            created_at: SystemTime::now(),
236            next_window_id: 0,
237        }
238    }
239
240    pub fn new_window(&mut self, pane: Pane) -> WindowId {
241        let window_id = self.next_window_id;
242        self.next_window_id += 1;
243        self.windows.push(Window {
244            id: window_id,
245            panes: vec![pane],
246            active_pane: 0,
247            layout: Layout::Single,
248        });
249        window_id
250    }
251
252    /// Kill a window by ID. Returns (session_empty, backends) so the
253    /// caller can close the backends outside the session manager lock.
254    pub fn kill_window(&mut self, window_id: WindowId) -> Result<(bool, Vec<Box<dyn PtyBackend>>)> {
255        let idx = self
256            .windows
257            .iter()
258            .position(|w| w.id == window_id)
259            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
260
261        let window = self.windows.remove(idx);
262        let backends: Vec<Box<dyn PtyBackend>> =
263            window.panes.into_iter().map(|p| p.pty).collect();
264
265        if self.windows.is_empty() {
266            return Ok((true, backends));
267        }
268        if self.active_window >= self.windows.len() {
269            self.active_window = self.windows.len() - 1;
270        }
271        Ok((false, backends))
272    }
273
274    pub fn to_info(&self) -> SessionInfo {
275        let created_at = self
276            .created_at
277            .duration_since(UNIX_EPOCH)
278            .unwrap_or_default()
279            .as_secs();
280        SessionInfo {
281            name: self.name.clone(),
282            windows: self.windows.iter().map(|w| w.to_info()).collect(),
283            created_at,
284        }
285    }
286
287    /// Find a window by ID.
288    pub fn find_window(&self, window_id: WindowId) -> Option<&Window> {
289        self.windows.iter().find(|w| w.id == window_id)
290    }
291
292    pub fn find_window_mut(&mut self, window_id: WindowId) -> Option<&mut Window> {
293        self.windows.iter_mut().find(|w| w.id == window_id)
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Screen feed task
299// ---------------------------------------------------------------------------
300
301/// Async task that reads PTY output and feeds it to the screen state machine.
302async fn run_screen_feed(
303    mut output_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
304    screen: Arc<RwLock<Screen>>,
305    update_tx: broadcast::Sender<()>,
306    raw_tx: broadcast::Sender<Vec<u8>>,
307) {
308    tracing::debug!("screen_feed: task started, waiting for PTY output");
309    while let Some(data) = output_rx.recv().await {
310        tracing::trace!("screen_feed: received {} bytes", data.len());
311        {
312            let mut s = screen.write().await;
313            s.feed(&data);
314        }
315        let _ = update_tx.send(());
316        let _ = raw_tx.send(data);
317    }
318    tracing::debug!("screen_feed: channel closed, task exiting");
319}
320
321/// Spawn the screen feed task and exit monitor task.
322#[allow(clippy::too_many_arguments)]
323fn spawn_pane_tasks(
324    output_rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
325    screen: Arc<RwLock<Screen>>,
326    exit_rx: tokio::sync::oneshot::Receiver<i32>,
327    update_tx: broadcast::Sender<()>,
328    raw_tx: broadcast::Sender<Vec<u8>>,
329    exit_tx: broadcast::Sender<i32>,
330    pane_id: PaneId,
331    manager: Option<Arc<tokio::sync::Mutex<SessionManager>>>,
332) {
333    // Screen feed task: feeds VT data to screen, notifies waiters, forwards raw bytes
334    tracing::debug!("spawn_pane_tasks: spawning screen feed for pane {}", pane_id);
335    tokio::spawn(run_screen_feed(output_rx, screen, update_tx, raw_tx));
336
337    // Exit monitor: updates pane state and optionally cascade-kills
338    tokio::spawn(async move {
339        if let Ok(code) = exit_rx.await {
340            // Broadcast the exit code to subscribers
341            let _ = exit_tx.send(code);
342
343            if let Some(mgr_arc) = manager {
344                let mut mgr = mgr_arc.lock().await;
345                // Update process state
346                if let Some(pane) = mgr.find_pane_mut(&pane_id) {
347                    pane.process.running = false;
348                    pane.process.exit_code = Some(code);
349                    tracing::info!("pane {} exited with code {}", pane_id, code);
350
351                    if !pane.options.remain_on_exit {
352                        drop(mgr);
353                        // Reacquire to cascade kill, then close backends outside lock
354                        let backends = {
355                            let mut mgr = mgr_arc.lock().await;
356                            mgr.kill_pane(&pane_id).unwrap_or_default()
357                        };
358                        for mut b in backends {
359                            let _ = b.close();
360                        }
361                    }
362                }
363            }
364        }
365    });
366}
367
368// ---------------------------------------------------------------------------
369// SessionManager
370// ---------------------------------------------------------------------------
371
372fn validate_mark_name(name: &str) -> Result<()> {
373    if name.is_empty() || name.len() > 32 {
374        bail!("mark name must be 1-32 characters, got {}", name.len());
375    }
376    if !name
377        .chars()
378        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
379    {
380        bail!(
381            "mark name must be alphanumeric, hyphens, or underscores: {}",
382            name
383        );
384    }
385    Ok(())
386}
387
388fn parse_layout_name(name: &str) -> Result<Layout> {
389    match name {
390        "single" => Ok(Layout::Single),
391        "even-horizontal" => Ok(Layout::EvenHorizontal),
392        "even-vertical" => Ok(Layout::EvenVertical),
393        "main-vertical" => Ok(Layout::MainVertical),
394        "tiled" => Ok(Layout::Tiled),
395        other => bail!(
396            "unknown layout: {} (valid: single, even-horizontal, even-vertical, main-vertical, tiled)",
397            other
398        ),
399    }
400}
401
402/// Recursively collect (mark, command) info from leaf nodes in DSL order.
403fn collect_leaf_info(
404    node: &aimux_common::config::LayoutNode,
405    out: &mut Vec<(Option<String>, Option<String>)>,
406) {
407    if node.direction.is_none() {
408        out.push((node.mark.clone(), node.command.clone()));
409    } else if let Some(ref children) = node.children {
410        for child in children {
411            collect_leaf_info(child, out);
412        }
413    }
414}
415
416pub struct SessionManager {
417    pub(crate) sessions: HashMap<SessionId, Session>,
418    next_pane_id: AtomicU32,
419    pty_factory: PtyFactory,
420    self_arc: Option<Weak<tokio::sync::Mutex<SessionManager>>>,
421    pub(crate) pane_index: HashMap<PaneId, (SessionId, WindowId)>,
422    config: AimuxConfig,
423    pub(crate) marks: HashMap<String, PaneId>,
424    start_time: std::time::Instant,
425}
426
427impl SessionManager {
428    pub fn new(pty_factory: PtyFactory, config: AimuxConfig) -> Self {
429        Self {
430            sessions: HashMap::new(),
431            next_pane_id: AtomicU32::new(0),
432            pty_factory,
433            self_arc: None,
434            pane_index: HashMap::new(),
435            config,
436            marks: HashMap::new(),
437            start_time: std::time::Instant::now(),
438        }
439    }
440
441    pub fn set_self_arc(&mut self, arc: &Arc<tokio::sync::Mutex<SessionManager>>) {
442        self.self_arc = Some(Arc::downgrade(arc));
443    }
444
445    pub fn allocate_pane_id(&self) -> PaneId {
446        let id = self.next_pane_id.fetch_add(1, Ordering::Relaxed);
447        format!("%{}", id)
448    }
449
450    pub fn default_session_options(&self) -> SessionOptions {
451        let mut opts = SessionOptions::default();
452        if let Some(ref shell) = self.config.default_shell {
453            opts.default_shell = shell.clone();
454        }
455        if let Some(ref pattern) = self.config.prompt_pattern {
456            opts.prompt_pattern = pattern.clone();
457        }
458        if let Some(limit) = self.config.scrollback_limit {
459            opts.scrollback_limit = limit;
460        }
461        opts
462    }
463
464    pub fn reload_config(&mut self, config: AimuxConfig) {
465        self.config = config;
466    }
467
468    pub fn server_status(&self) -> aimux_protocol::ServerStatusInfo {
469        let uptime_secs = self.start_time.elapsed().as_secs();
470        let mut windows: u32 = 0;
471        let mut panes: u32 = 0;
472        for session in self.sessions.values() {
473            windows += session.windows.len() as u32;
474            for window in &session.windows {
475                panes += window.panes.len() as u32;
476            }
477        }
478        aimux_protocol::ServerStatusInfo {
479            version: String::new(), // filled by dispatch with CARGO_PKG_VERSION
480            uptime_secs,
481            sessions: self.sessions.len() as u32,
482            windows,
483            panes,
484            pid: 0, // filled by dispatch with std::process::id()
485        }
486    }
487
488    pub fn config(&self) -> &AimuxConfig {
489        &self.config
490    }
491
492    pub(crate) fn config_mut(&mut self) -> &mut AimuxConfig {
493        &mut self.config
494    }
495
496    // -- Pane marks --
497
498    pub fn resolve_pane_target(&self, target: &str) -> Result<String> {
499        if let Some(mark_name) = target.strip_prefix('@') {
500            self.marks
501                .get(mark_name)
502                .cloned()
503                .ok_or_else(|| anyhow::anyhow!("mark not found: @{}", mark_name))
504        } else {
505            Ok(target.to_string())
506        }
507    }
508
509    pub fn set_mark(&mut self, mark: &str, pane_id: &str) -> Result<()> {
510        validate_mark_name(mark)?;
511        if !self.pane_index.contains_key(pane_id) {
512            bail!("pane not found: {}", pane_id);
513        }
514        self.marks.insert(mark.to_string(), pane_id.to_string());
515        Ok(())
516    }
517
518    pub fn remove_mark(&mut self, mark: &str) -> Result<()> {
519        self.marks
520            .remove(mark)
521            .ok_or_else(|| anyhow::anyhow!("mark not found: {}", mark))?;
522        Ok(())
523    }
524
525    pub fn list_marks(&self) -> Vec<(String, String)> {
526        self.marks
527            .iter()
528            .map(|(k, v)| (k.clone(), v.clone()))
529            .collect()
530    }
531
532    pub fn marks_for_pane(&self, pane_id: &str) -> Vec<String> {
533        self.marks
534            .iter()
535            .filter(|(_, v)| v.as_str() == pane_id)
536            .map(|(k, _)| k.clone())
537            .collect()
538    }
539
540    fn inject_marks(&self, pane_infos: &mut [PaneInfo]) {
541        for info in pane_infos.iter_mut() {
542            info.marks = self.marks_for_pane(&info.id);
543        }
544    }
545
546    /// Apply a named layout from config to a window.
547    /// Creates/kills panes to match leaf count, sets Custom layout, applies marks.
548    /// Returns (pane_ids, backends_to_close) so the caller can close backends outside the lock.
549    pub fn apply_layout(
550        &mut self,
551        session_name: &str,
552        window_id: WindowId,
553        layout_name: &str,
554    ) -> LayoutResult {
555        // Try config first, then fall back to saved layouts / presets via load_layout
556        let layout_node = self
557            .config
558            .layouts
559            .as_ref()
560            .and_then(|layouts| layouts.get(layout_name))
561            .cloned();
562
563        let layout_node = match layout_node {
564            Some(node) => node,
565            None => {
566                // Delegate to load_layout which handles saved files and presets
567                return self.load_layout(session_name, window_id, layout_name);
568            }
569        };
570
571        let tree = crate::layout_dsl::config_to_tree(&layout_node)
572            .context("invalid layout definition")?;
573        let leaf_count = tree.leaf_count();
574        if leaf_count == 0 {
575            bail!("layout must have at least one leaf pane");
576        }
577
578        // Collect leaf marks/commands from the layout DSL
579        let mut leaf_info: Vec<(Option<String>, Option<String>)> = Vec::new();
580        collect_leaf_info(&layout_node, &mut leaf_info);
581
582        let session = self
583            .sessions
584            .get(session_name)
585            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
586        let default_shell = session.options.default_shell.clone();
587        let scrollback_limit = session.options.scrollback_limit;
588        let window = session
589            .find_window(window_id)
590            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
591        let current_count = window.panes.len();
592
593        // Kill excess panes from the end — collect backends instead of closing
594        let mut killed_backends = Vec::new();
595        if current_count > leaf_count {
596            let to_kill: Vec<PaneId> = {
597                let session = self.sessions.get(session_name).unwrap();
598                let window = session.find_window(window_id).unwrap();
599                window.panes[leaf_count..]
600                    .iter()
601                    .map(|p| p.id.clone())
602                    .collect()
603            };
604            for pane_id in &to_kill {
605                self.pane_index.remove(pane_id);
606                self.marks.retain(|_, v| v.as_str() != pane_id.as_str());
607            }
608            let session = self.sessions.get_mut(session_name).unwrap();
609            let window = session.find_window_mut(window_id).unwrap();
610            for pane_id in &to_kill {
611                if let Some(idx) = window.panes.iter().position(|p| p.id == *pane_id) {
612                    let pane = window.panes.remove(idx);
613                    killed_backends.push(pane.pty);
614                }
615            }
616        }
617
618        // Create additional panes if needed
619        if current_count < leaf_count {
620            let needed = leaf_count - current_count;
621            let mut new_panes = Vec::with_capacity(needed);
622            for _ in 0..needed {
623                let pane = self.create_pane(&default_shell, 80, 24, scrollback_limit)?;
624                new_panes.push(pane);
625            }
626            for pane in new_panes {
627                let pane_id = pane.id.clone();
628                self.pane_index
629                    .insert(pane_id, (session_name.to_string(), window_id));
630                let session = self.sessions.get_mut(session_name).unwrap();
631                let window = session.find_window_mut(window_id).unwrap();
632                window.panes.push(pane);
633            }
634        }
635
636        // Set the Custom layout
637        let session = self.sessions.get_mut(session_name).unwrap();
638        let window = session.find_window_mut(window_id).unwrap();
639        window.layout = Layout::Custom(tree);
640        if window.active_pane >= window.panes.len() {
641            window.active_pane = window.panes.len().saturating_sub(1);
642        }
643
644        // Collect pane IDs and apply marks
645        let pane_ids: Vec<PaneId> = window.panes.iter().map(|p| p.id.clone()).collect();
646        for (i, (mark, _cmd)) in leaf_info.iter().enumerate() {
647            if let Some(mark_name) = mark {
648                if i < pane_ids.len() {
649                    let _ = self.set_mark(mark_name, &pane_ids[i]);
650                }
651            }
652        }
653
654        Ok((pane_ids, killed_backends))
655    }
656
657    /// Select a preset layout for an existing window.
658    pub fn select_layout(
659        &mut self,
660        session_name: &str,
661        window_id: WindowId,
662        layout_name: &str,
663    ) -> Result<()> {
664        let layout = parse_layout_name(layout_name)?;
665        let session = self
666            .sessions
667            .get_mut(session_name)
668            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
669        let window = session
670            .find_window_mut(window_id)
671            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
672        window.layout = layout;
673        Ok(())
674    }
675
676    pub(crate) fn create_pane(
677        &self,
678        shell: &str,
679        cols: u16,
680        rows: u16,
681        scrollback_limit: usize,
682    ) -> Result<Pane> {
683        let pane_id = self.allocate_pane_id();
684        let spawn_result = (self.pty_factory)(shell, cols, rows)?;
685
686        let screen = Arc::new(RwLock::new(Screen::new(cols, rows, scrollback_limit)));
687
688        // Broadcast channels for AI orchestration (wait, subscribe, exec)
689        let (update_tx, _) = broadcast::channel(64);
690        let (raw_tx, _) = broadcast::channel(64);
691        let (exit_tx, _) = broadcast::channel(4);
692
693        let manager_arc = self.self_arc.as_ref().and_then(|w| w.upgrade());
694
695        // Spawn the screen feed and exit monitor tasks.
696        spawn_pane_tasks(
697            spawn_result.output_rx,
698            screen.clone(),
699            spawn_result.exit_rx,
700            update_tx.clone(),
701            raw_tx.clone(),
702            exit_tx.clone(),
703            pane_id.clone(),
704            manager_arc,
705        );
706
707        Ok(Pane {
708            id: pane_id,
709            pty: spawn_result.backend,
710            screen,
711            process: ProcessInfo {
712                pid: spawn_result.pid,
713                running: true,
714                exit_code: None,
715            },
716            size: (cols, rows),
717            title: shell.to_string(),
718            shell_command: shell.to_string(),
719            options: PaneOptions::default(),
720            update_tx,
721            raw_tx,
722            exit_tx,
723        })
724    }
725
726    pub fn new_session(
727        &mut self,
728        name: &str,
729        shell: Option<&str>,
730    ) -> Result<(SessionId, PaneId)> {
731        if self.sessions.contains_key(name) {
732            bail!("session already exists: {}", name);
733        }
734
735        let mut session = Session::new(name);
736        session.options = self.default_session_options();
737        let shell = shell.unwrap_or(&session.options.default_shell).to_string();
738        let scrollback_limit = session.options.scrollback_limit;
739        let pane = self.create_pane(&shell, 80, 24, scrollback_limit)?;
740        let pane_id = pane.id.clone();
741
742        let window_id = session.new_window(pane);
743        let session_id = session.name.clone();
744        self.pane_index.insert(pane_id.clone(), (session_id.clone(), window_id));
745        self.sessions.insert(session_id.clone(), session);
746
747        Ok((session_id, pane_id))
748    }
749
750    pub fn kill_session(&mut self, name: &str) -> Result<Vec<Box<dyn PtyBackend>>> {
751        let session = self
752            .sessions
753            .remove(name)
754            .ok_or_else(|| anyhow::anyhow!("session not found: {}", name))?;
755
756        let pane_ids: Vec<String> = session
757            .windows
758            .iter()
759            .flat_map(|w| w.panes.iter().map(|p| p.id.clone()))
760            .collect();
761
762        let mut backends = Vec::new();
763        for window in session.windows {
764            for pane in window.panes {
765                self.pane_index.remove(&pane.id);
766                backends.push(pane.pty);
767            }
768        }
769
770        self.marks.retain(|_, v| !pane_ids.contains(v));
771        Ok(backends)
772    }
773
774    pub fn list_sessions(&self) -> Vec<SessionInfo> {
775        let mut sessions: Vec<SessionInfo> = self.sessions.values().map(|s| s.to_info()).collect();
776        for session in &mut sessions {
777            for window in &mut session.windows {
778                self.inject_marks(&mut window.panes);
779            }
780        }
781        sessions
782    }
783
784    pub fn has_session(&self, name: &str) -> bool {
785        self.sessions.contains_key(name)
786    }
787
788    #[allow(dead_code)]
789    pub fn get_session(&self, name: &str) -> Option<&Session> {
790        self.sessions.get(name)
791    }
792
793    pub fn get_session_mut(&mut self, name: &str) -> Option<&mut Session> {
794        self.sessions.get_mut(name)
795    }
796
797    /// Find a pane by its global ID across all sessions.
798    pub fn find_pane(&self, pane_id: &str) -> Option<(&Session, &Window, &Pane)> {
799        let (session_id, window_id) = self.pane_index.get(pane_id)?;
800        let session = self.sessions.get(session_id)?;
801        let window = session.find_window(*window_id)?;
802        let pane = window.panes.iter().find(|p| p.id == pane_id)?;
803        Some((session, window, pane))
804    }
805
806    /// Find a mutable pane by its global ID.
807    pub fn find_pane_mut(&mut self, pane_id: &str) -> Option<&mut Pane> {
808        let (session_id, window_id) = self.pane_index.get(pane_id)?;
809        let session_id = session_id.clone();
810        let window_id = *window_id;
811        let session = self.sessions.get_mut(&session_id)?;
812        let window = session.find_window_mut(window_id)?;
813        window.panes.iter_mut().find(|p| p.id == pane_id)
814    }
815
816    /// Get the prompt pattern for a pane's parent session.
817    pub fn get_prompt_pattern(&self, pane_id: &str) -> Option<String> {
818        self.find_pane(pane_id)
819            .map(|(session, _, _)| session.options.prompt_pattern.clone())
820    }
821
822    /// Send keys to a pane. Returns error if the pane has exited.
823    pub fn send_keys(&self, pane_id: &str, keys: &str) -> Result<()> {
824        let (_, _, pane) = self
825            .find_pane(pane_id)
826            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
827        if !pane.process.running {
828            bail!("pane {} has exited", pane_id);
829        }
830        pane.pty.write(keys.as_bytes())
831    }
832
833    /// Get the screen and process info needed for a capture, without awaiting.
834    /// Returns (screen_arc, process_info) so the caller can await the read lock
835    /// without holding a reference to SessionManager across the await point.
836    pub fn get_capture_info(
837        &self,
838        pane_id: &str,
839    ) -> Result<(Arc<RwLock<Screen>>, ProcessInfo)> {
840        let (_, _, pane) = self
841            .find_pane(pane_id)
842            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
843        Ok((pane.screen.clone(), pane.process.clone()))
844    }
845
846    /// Create a new window in a session.
847    pub fn new_window(
848        &mut self,
849        session_name: &str,
850        shell: Option<&str>,
851    ) -> Result<(WindowId, PaneId)> {
852        let (default_shell, scrollback_limit) = self
853            .sessions
854            .get(session_name)
855            .map(|s| (s.options.default_shell.clone(), s.options.scrollback_limit))
856            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
857
858        let shell_cmd = shell.unwrap_or(&default_shell).to_string();
859        let pane = self.create_pane(&shell_cmd, 80, 24, scrollback_limit)?;
860        let pane_id = pane.id.clone();
861
862        let session = self
863            .sessions
864            .get_mut(session_name)
865            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
866        let window_id = session.new_window(pane);
867        self.pane_index.insert(pane_id.clone(), (session_name.to_string(), window_id));
868        Ok((window_id, pane_id))
869    }
870
871    /// Kill a window. If it was the last window, kills the session too.
872    /// Returns backends for the caller to close outside the lock.
873    pub fn kill_window(&mut self, session_name: &str, window_id: WindowId) -> Result<Vec<Box<dyn PtyBackend>>> {
874        let session = self
875            .sessions
876            .get_mut(session_name)
877            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
878
879        // Collect pane IDs for mark cleanup, and remove from index
880        let mut pane_ids = Vec::new();
881        if let Some(window) = session.find_window(window_id) {
882            for pane in &window.panes {
883                pane_ids.push(pane.id.clone());
884                self.pane_index.remove(&pane.id);
885            }
886        }
887
888        let (session_empty, backends) = session.kill_window(window_id)?;
889        if session_empty {
890            self.sessions.remove(session_name);
891        }
892
893        self.marks.retain(|_, v| !pane_ids.contains(v));
894        Ok(backends)
895    }
896
897    /// Split a pane, adding a new pane to the same window.
898    pub fn split_pane(
899        &mut self,
900        pane_id: &str,
901        horizontal: bool,
902        shell: Option<&str>,
903    ) -> Result<PaneId> {
904        let (session_name, window_id) = self.pane_index
905            .get(pane_id)
906            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
907        let session_name = session_name.clone();
908        let window_id = *window_id;
909
910        let session = self.sessions.get(&session_name)
911            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
912        let default_shell = session.options.default_shell.clone();
913        let scrollback_limit = session.options.scrollback_limit;
914
915        let shell_cmd = shell.unwrap_or(&default_shell).to_string();
916        let new_pane = self.create_pane(&shell_cmd, 80, 24, scrollback_limit)?;
917        let new_pane_id = new_pane.id.clone();
918
919        let session = self
920            .sessions
921            .get_mut(&session_name)
922            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
923        let window = session
924            .find_window_mut(window_id)
925            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
926        window.split_pane(new_pane, horizontal);
927        self.pane_index.insert(new_pane_id.clone(), (session_name, window_id));
928
929        Ok(new_pane_id)
930    }
931
932    /// Kill a pane. Cascades: last pane kills window, last window kills session.
933    /// Returns backends for the caller to close outside the lock.
934    pub fn kill_pane(&mut self, pane_id: &str) -> Result<Vec<Box<dyn PtyBackend>>> {
935        let (session_name, window_id) = self.pane_index
936            .remove(pane_id)
937            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
938
939        let session = self
940            .sessions
941            .get_mut(&session_name)
942            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
943        let window = session
944            .find_window_mut(window_id)
945            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
946
947        let (window_empty, backend) = window.kill_pane(pane_id)?;
948        let mut backends = vec![backend];
949        if window_empty {
950            let (session_empty, mut window_backends) = session.kill_window(window_id)?;
951            backends.append(&mut window_backends);
952            if session_empty {
953                self.sessions.remove(&session_name);
954            }
955        }
956
957        self.marks.retain(|_, v| v.as_str() != pane_id);
958        Ok(backends)
959    }
960
961    /// List panes for a session.
962    pub fn list_panes(&self, session_name: &str) -> Result<Vec<PaneInfo>> {
963        let session = self
964            .sessions
965            .get(session_name)
966            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
967        let mut panes = Vec::new();
968        for window in &session.windows {
969            for pane in &window.panes {
970                panes.push(pane.to_info());
971            }
972        }
973        self.inject_marks(&mut panes);
974        Ok(panes)
975    }
976
977    /// Select a window as active in a session.
978    pub fn select_window(&mut self, session_name: &str, window_id: WindowId) -> Result<()> {
979        let session = self
980            .sessions
981            .get_mut(session_name)
982            .ok_or_else(|| anyhow::anyhow!("session \"{}\" not found", session_name))?;
983        let idx = session
984            .windows
985            .iter()
986            .position(|w| w.id == window_id)
987            .ok_or_else(|| {
988                anyhow::anyhow!(
989                    "window {} not found in session \"{}\"",
990                    window_id,
991                    session_name
992                )
993            })?;
994        session.active_window = idx;
995        Ok(())
996    }
997
998    /// Select a pane as active, finding it across all sessions.
999    pub fn select_pane(&mut self, pane_id: &str) -> Result<()> {
1000        let (session_id, window_id) = self.pane_index
1001            .get(pane_id)
1002            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
1003        let session_id = session_id.clone();
1004        let window_id = *window_id;
1005        let session = self.sessions.get_mut(&session_id)
1006            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_id))?;
1007        let window = session.find_window_mut(window_id)
1008            .ok_or_else(|| anyhow::anyhow!("window not found: {}", window_id))?;
1009        let idx = window.panes.iter().position(|p| p.id == pane_id)
1010            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
1011        window.active_pane = idx;
1012        Ok(())
1013    }
1014
1015    /// Swap two panes' positions within the same window.
1016    pub fn swap_panes(&mut self, pane_a: &str, pane_b: &str) -> Result<()> {
1017        let (session_a, window_a) = self
1018            .pane_index
1019            .get(pane_a)
1020            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_a))?;
1021        let (session_b, window_b) = self
1022            .pane_index
1023            .get(pane_b)
1024            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_b))?;
1025
1026        if session_a != session_b || window_a != window_b {
1027            bail!("panes must be in the same window to swap");
1028        }
1029
1030        let session_name = session_a.clone();
1031        let window_id = *window_a;
1032
1033        if pane_a == pane_b {
1034            return Ok(());
1035        }
1036
1037        let session = self.sessions.get_mut(&session_name).unwrap();
1038        let window = session.find_window_mut(window_id).unwrap();
1039
1040        let idx_a = window.panes.iter().position(|p| p.id == pane_a).unwrap();
1041        let idx_b = window.panes.iter().position(|p| p.id == pane_b).unwrap();
1042
1043        window.panes.swap(idx_a, idx_b);
1044
1045        if window.active_pane == idx_a {
1046            window.active_pane = idx_b;
1047        } else if window.active_pane == idx_b {
1048            window.active_pane = idx_a;
1049        }
1050
1051        Ok(())
1052    }
1053
1054    /// Move a pane from its current window to a different window.
1055    pub fn move_pane(
1056        &mut self,
1057        pane_id: &str,
1058        target_session: &str,
1059        target_window_id: WindowId,
1060    ) -> Result<()> {
1061        // 1. Look up source location
1062        let (src_session, src_window) = self
1063            .pane_index
1064            .get(pane_id)
1065            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
1066        let src_session = src_session.clone();
1067        let src_window = *src_window;
1068
1069        // 2. Verify target exists
1070        let target_session_name = target_session.to_string();
1071        if !self.sessions.contains_key(&target_session_name) {
1072            bail!("session not found: {}", target_session);
1073        }
1074        {
1075            let session = self.sessions.get(&target_session_name).unwrap();
1076            if session.find_window(target_window_id).is_none() {
1077                bail!(
1078                    "window not found: {}:{}",
1079                    target_session,
1080                    target_window_id
1081                );
1082            }
1083        }
1084
1085        // 3. No-op if same window
1086        if src_session == target_session_name && src_window == target_window_id {
1087            return Ok(());
1088        }
1089
1090        // 4. Remove pane from source window
1091        let pane = {
1092            let session = self.sessions.get_mut(&src_session).unwrap();
1093            let window = session.find_window_mut(src_window).unwrap();
1094            let idx = window.panes.iter().position(|p| p.id == pane_id).unwrap();
1095            let pane = window.panes.remove(idx);
1096            if window.panes.is_empty() {
1097                window.active_pane = 0;
1098            } else if window.active_pane >= window.panes.len() {
1099                window.active_pane = window.panes.len() - 1;
1100            }
1101            if window.panes.len() == 1 {
1102                window.layout = Layout::Single;
1103            } else if matches!(window.layout, Layout::Custom(_)) {
1104                window.layout = Layout::EvenHorizontal;
1105            }
1106            pane
1107        };
1108
1109        // 5. Insert pane into target window
1110        {
1111            let session = self.sessions.get_mut(&target_session_name).unwrap();
1112            let window = session.find_window_mut(target_window_id).unwrap();
1113            window.panes.push(pane);
1114            window.active_pane = window.panes.len() - 1;
1115            if matches!(window.layout, Layout::Custom(_))
1116                || (window.panes.len() == 2 && window.layout == Layout::Single)
1117            {
1118                window.layout = Layout::EvenHorizontal;
1119            }
1120        }
1121
1122        // 6. Update pane index
1123        self.pane_index.insert(
1124            pane_id.to_string(),
1125            (target_session_name, target_window_id),
1126        );
1127
1128        // 7. Cascade: if source window is now empty, kill it
1129        // (no backends to close — the window is already empty)
1130        {
1131            let session = self.sessions.get_mut(&src_session).unwrap();
1132            let window = session.find_window(src_window);
1133            if window.is_some_and(|w| w.panes.is_empty()) {
1134                let (session_empty, _) = session.kill_window(src_window)?;
1135                if session_empty {
1136                    self.sessions.remove(&src_session);
1137                }
1138            }
1139        }
1140
1141        Ok(())
1142    }
1143
1144    /// List windows for a session.
1145    pub fn list_windows(&self, session_name: &str) -> Result<Vec<WindowInfo>> {
1146        let session = self
1147            .sessions
1148            .get(session_name)
1149            .ok_or_else(|| anyhow::anyhow!("session not found: {}", session_name))?;
1150        Ok(session.windows.iter().map(|w| w.to_info()).collect())
1151    }
1152
1153    /// Resize a pane's PTY backend and update its stored size.
1154    /// Returns the screen Arc so the caller can resize the screen after dropping the manager lock.
1155    pub fn resize_pane_pty(
1156        &mut self,
1157        pane_id: &str,
1158        cols: u16,
1159        rows: u16,
1160    ) -> Result<Arc<RwLock<Screen>>> {
1161        if cols < MIN_PANE_COLS || rows < MIN_PANE_ROWS {
1162            bail!(
1163                "dimensions too small: {}x{} (minimum {}x{})",
1164                cols, rows, MIN_PANE_COLS, MIN_PANE_ROWS
1165            );
1166        }
1167        if cols > MAX_PANE_COLS || rows > MAX_PANE_ROWS {
1168            bail!(
1169                "dimensions too large: {}x{} (maximum {}x{})",
1170                cols, rows, MAX_PANE_COLS, MAX_PANE_ROWS
1171            );
1172        }
1173
1174        let pane = self
1175            .find_pane_mut(pane_id)
1176            .ok_or_else(|| anyhow::anyhow!("pane not found: {}", pane_id))?;
1177
1178        if !pane.process.running {
1179            bail!("pane {} has exited", pane_id);
1180        }
1181
1182        pane.pty.resize(cols, rows)?;
1183        pane.size = (cols, rows);
1184        Ok(pane.screen.clone())
1185    }
1186
1187    /// Shut down all sessions. Returns backends for the caller to close.
1188    pub fn shutdown_all(&mut self) -> Vec<Box<dyn PtyBackend>> {
1189        self.pane_index.clear();
1190        self.marks.clear();
1191        let sessions = std::mem::take(&mut self.sessions);
1192        let mut backends = Vec::new();
1193        for (_, session) in sessions {
1194            for window in session.windows {
1195                for pane in window.panes {
1196                    backends.push(pane.pty);
1197                }
1198            }
1199        }
1200        backends
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------
1205// Shared test helpers
1206// ---------------------------------------------------------------------------
1207
1208#[cfg(test)]
1209pub mod testing {
1210    use super::*;
1211    use std::sync::atomic::{AtomicU32, Ordering};
1212
1213    pub struct MockPty {
1214        pid: u32,
1215    }
1216
1217    impl PtyBackend for MockPty {
1218        fn write(&self, _data: &[u8]) -> Result<()> {
1219            Ok(())
1220        }
1221        fn resize(&self, _cols: u16, _rows: u16) -> Result<()> {
1222            Ok(())
1223        }
1224        fn close(&mut self) -> Result<()> {
1225            Ok(())
1226        }
1227        fn pid(&self) -> u32 {
1228            self.pid
1229        }
1230    }
1231
1232    pub fn mock_factory() -> PtyFactory {
1233        let pid = AtomicU32::new(1000);
1234        Box::new(move |_cmd: &str, _cols: u16, _rows: u16| {
1235            let p = pid.fetch_add(1, Ordering::Relaxed);
1236            let (_, output_rx) = tokio::sync::mpsc::unbounded_channel();
1237            let (_, exit_rx) = tokio::sync::oneshot::channel();
1238            Ok(PtySpawnResult {
1239                backend: Box::new(MockPty { pid: p }),
1240                pid: p,
1241                output_rx,
1242                exit_rx,
1243            })
1244        })
1245    }
1246
1247    pub fn new_manager() -> SessionManager {
1248        SessionManager::new(mock_factory(), AimuxConfig::default())
1249    }
1250
1251    pub fn new_manager_arc() -> std::sync::Arc<tokio::sync::Mutex<SessionManager>> {
1252        std::sync::Arc::new(tokio::sync::Mutex::new(new_manager()))
1253    }
1254}
1255
1256// ---------------------------------------------------------------------------
1257// Tests
1258// ---------------------------------------------------------------------------
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::testing::new_manager;
1263    use super::Layout;
1264
1265    #[tokio::test]
1266    async fn create_and_list_session() {
1267        let mut mgr = new_manager();
1268        let (name, pane_id) = mgr.new_session("work", None).unwrap();
1269        assert_eq!(name, "work");
1270        assert_eq!(pane_id, "%0");
1271
1272        let sessions = mgr.list_sessions();
1273        assert_eq!(sessions.len(), 1);
1274        assert_eq!(sessions[0].name, "work");
1275        assert_eq!(sessions[0].windows.len(), 1);
1276        assert_eq!(sessions[0].windows[0].panes.len(), 1);
1277    }
1278
1279    #[tokio::test]
1280    async fn duplicate_session_name_errors() {
1281        let mut mgr = new_manager();
1282        mgr.new_session("dup", None).unwrap();
1283        let err = mgr.new_session("dup", None).unwrap_err();
1284        assert!(err.to_string().contains("already exists"));
1285    }
1286
1287    #[tokio::test]
1288    async fn kill_session() {
1289        let mut mgr = new_manager();
1290        mgr.new_session("temp", None).unwrap();
1291        assert!(mgr.has_session("temp"));
1292
1293        mgr.kill_session("temp").unwrap();
1294        assert!(!mgr.has_session("temp"));
1295        assert!(mgr.list_sessions().is_empty());
1296    }
1297
1298    #[tokio::test]
1299    async fn has_session() {
1300        let mut mgr = new_manager();
1301        assert!(!mgr.has_session("nope"));
1302        mgr.new_session("yes", None).unwrap();
1303        assert!(mgr.has_session("yes"));
1304    }
1305
1306    #[tokio::test]
1307    async fn new_window_in_session() {
1308        let mut mgr = new_manager();
1309        mgr.new_session("s1", None).unwrap();
1310        let (win_id, pane_id) = mgr.new_window("s1", None).unwrap();
1311        assert_eq!(win_id, 1);
1312        assert_eq!(pane_id, "%1");
1313
1314        let windows = mgr.list_windows("s1").unwrap();
1315        assert_eq!(windows.len(), 2);
1316    }
1317
1318    #[tokio::test]
1319    async fn kill_window() {
1320        let mut mgr = new_manager();
1321        mgr.new_session("s1", None).unwrap();
1322        mgr.new_window("s1", None).unwrap();
1323
1324        mgr.kill_window("s1", 0).unwrap();
1325        let windows = mgr.list_windows("s1").unwrap();
1326        assert_eq!(windows.len(), 1);
1327        assert_eq!(windows[0].id, 1);
1328    }
1329
1330    #[tokio::test]
1331    async fn split_pane() {
1332        let mut mgr = new_manager();
1333        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1334        let pane1 = mgr.split_pane(&pane0, true, None).unwrap();
1335        assert_eq!(pane1, "%1");
1336
1337        let panes = mgr.list_panes("s1").unwrap();
1338        assert_eq!(panes.len(), 2);
1339    }
1340
1341    #[tokio::test]
1342    async fn kill_pane() {
1343        let mut mgr = new_manager();
1344        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1345        let pane1 = mgr.split_pane(&pane0, true, None).unwrap();
1346
1347        mgr.kill_pane(&pane1).unwrap();
1348        let panes = mgr.list_panes("s1").unwrap();
1349        assert_eq!(panes.len(), 1);
1350        assert_eq!(panes[0].id, pane0);
1351    }
1352
1353    #[tokio::test]
1354    async fn kill_cascade_pane_to_window_to_session() {
1355        let mut mgr = new_manager();
1356        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1357
1358        mgr.kill_pane(&pane0).unwrap();
1359        assert!(!mgr.has_session("s1"));
1360    }
1361
1362    #[tokio::test]
1363    async fn kill_last_window_kills_session() {
1364        let mut mgr = new_manager();
1365        mgr.new_session("s1", None).unwrap();
1366
1367        mgr.kill_window("s1", 0).unwrap();
1368        assert!(!mgr.has_session("s1"));
1369    }
1370
1371    #[tokio::test]
1372    async fn pane_ids_globally_unique_and_sequential() {
1373        let mut mgr = new_manager();
1374        mgr.new_session("s1", None).unwrap(); // %0
1375        mgr.new_session("s2", None).unwrap(); // %1
1376        let pane2 = mgr.split_pane("%0", true, None).unwrap(); // %2
1377        assert_eq!(pane2, "%2");
1378
1379        let (_, pane3) = mgr.new_window("s1", None).unwrap(); // %3
1380        assert_eq!(pane3, "%3");
1381    }
1382
1383    #[tokio::test]
1384    async fn find_pane_across_sessions() {
1385        let mut mgr = new_manager();
1386        mgr.new_session("s1", None).unwrap(); // %0
1387        mgr.new_session("s2", None).unwrap(); // %1
1388
1389        let result = mgr.find_pane("%1");
1390        assert!(result.is_some());
1391        let (session, _window, pane) = result.unwrap();
1392        assert_eq!(session.name, "s2");
1393        assert_eq!(pane.id, "%1");
1394
1395        assert!(mgr.find_pane("%99").is_none());
1396    }
1397
1398    #[tokio::test]
1399    async fn select_window() {
1400        let mut mgr = new_manager();
1401        mgr.new_session("s1", None).unwrap();
1402        mgr.new_window("s1", None).unwrap(); // window 1
1403
1404        // Default active is 0
1405        assert_eq!(mgr.get_session("s1").unwrap().active_window, 0);
1406
1407        mgr.select_window("s1", 1).unwrap();
1408        assert_eq!(mgr.get_session("s1").unwrap().active_window, 1);
1409
1410        // Non-existent window
1411        let err = mgr.select_window("s1", 99).unwrap_err();
1412        assert!(err.to_string().contains("not found"));
1413
1414        // Non-existent session
1415        let err = mgr.select_window("nope", 0).unwrap_err();
1416        assert!(err.to_string().contains("not found"));
1417    }
1418
1419    #[tokio::test]
1420    async fn select_pane() {
1421        let mut mgr = new_manager();
1422        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1423        let pane1 = mgr.split_pane(&pane0, true, None).unwrap();
1424
1425        // Default active pane is 0
1426        let (_, window, _) = mgr.find_pane(&pane0).unwrap();
1427        assert_eq!(window.active_pane, 0);
1428
1429        mgr.select_pane(&pane1).unwrap();
1430        let (_, window, _) = mgr.find_pane(&pane0).unwrap();
1431        assert_eq!(window.active_pane, 1);
1432
1433        // Non-existent pane
1434        let err = mgr.select_pane("%99").unwrap_err();
1435        assert!(err.to_string().contains("pane not found"));
1436    }
1437
1438    #[tokio::test]
1439    async fn send_keys_to_pane() {
1440        let mut mgr = new_manager();
1441        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1442        mgr.send_keys(&pane_id, "hello\r").unwrap();
1443    }
1444
1445    #[tokio::test]
1446    async fn send_keys_unknown_pane_errors() {
1447        let mgr = new_manager();
1448        let err = mgr.send_keys("%99", "hello").unwrap_err();
1449        assert!(err.to_string().contains("pane not found"));
1450    }
1451
1452    #[tokio::test]
1453    async fn capture_pane_returns_screen_content() {
1454        let mut mgr = new_manager();
1455        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1456
1457        // Feed data directly into the screen for testing
1458        {
1459            let (_, _, pane) = mgr.find_pane(&pane_id).unwrap();
1460            let mut screen = pane.screen.write().await;
1461            screen.feed(b"hello world");
1462        }
1463
1464        let (screen_arc, process) = mgr.get_capture_info(&pane_id).unwrap();
1465        let screen = screen_arc.read().await;
1466        assert_eq!(screen.capture_text()[0], "hello world");
1467        assert_eq!(screen.size(), (80, 24));
1468        assert!(process.running);
1469    }
1470
1471    #[tokio::test]
1472    async fn set_default_shell_option() {
1473        let mut mgr = new_manager();
1474        mgr.new_session("s1", None).unwrap();
1475
1476        // Change default shell
1477        let session = mgr.get_session_mut("s1").unwrap();
1478        session.options.default_shell = "cmd.exe".to_string();
1479
1480        // New window should use the updated default
1481        let (_, pane_id) = mgr.new_window("s1", None).unwrap();
1482        let (_, _, pane) = mgr.find_pane(&pane_id).unwrap();
1483        // The pane title reflects the shell command
1484        assert_eq!(pane.title, "cmd.exe");
1485    }
1486
1487    #[tokio::test]
1488    async fn set_prompt_pattern_option() {
1489        let mut mgr = new_manager();
1490        mgr.new_session("s1", None).unwrap();
1491
1492        // Change prompt pattern
1493        let session = mgr.get_session_mut("s1").unwrap();
1494        session.options.prompt_pattern = r"PS.*>".to_string();
1495
1496        // Verify via get_prompt_pattern
1497        let pattern = mgr.get_prompt_pattern("%0");
1498        assert_eq!(pattern, Some(r"PS.*>".to_string()));
1499    }
1500
1501    #[tokio::test]
1502    async fn remain_on_exit_option() {
1503        let mut mgr = new_manager();
1504        mgr.new_session("s1", None).unwrap();
1505
1506        // Set remain-on-exit on
1507        let pane = mgr.find_pane_mut("%0").unwrap();
1508        pane.options.remain_on_exit = true;
1509        assert!(pane.options.remain_on_exit);
1510
1511        // Set remain-on-exit off
1512        let pane = mgr.find_pane_mut("%0").unwrap();
1513        pane.options.remain_on_exit = false;
1514        assert!(!pane.options.remain_on_exit);
1515    }
1516
1517    #[tokio::test]
1518    async fn send_keys_to_dead_pane_errors() {
1519        let mut mgr = new_manager();
1520        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1521
1522        // Simulate process exit
1523        let pane = mgr.find_pane_mut(&pane_id).unwrap();
1524        pane.process.running = false;
1525        pane.process.exit_code = Some(0);
1526
1527        // send_keys should fail
1528        let err = mgr.send_keys(&pane_id, "hello").unwrap_err();
1529        assert!(err.to_string().contains("has exited"));
1530    }
1531
1532    #[tokio::test]
1533    async fn capture_dead_pane_still_works() {
1534        let mut mgr = new_manager();
1535        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1536
1537        // Feed some data
1538        {
1539            let (_, _, pane) = mgr.find_pane(&pane_id).unwrap();
1540            let mut screen = pane.screen.write().await;
1541            screen.feed(b"last output");
1542        }
1543
1544        // Simulate process exit
1545        let pane = mgr.find_pane_mut(&pane_id).unwrap();
1546        pane.options.remain_on_exit = true;
1547        pane.process.running = false;
1548        pane.process.exit_code = Some(0);
1549
1550        // Capture should still work
1551        let (screen_arc, process) = mgr.get_capture_info(&pane_id).unwrap();
1552        let screen = screen_arc.read().await;
1553        assert_eq!(screen.capture_text()[0], "last output");
1554        assert!(!process.running);
1555        assert_eq!(process.exit_code, Some(0));
1556    }
1557
1558    #[tokio::test]
1559    async fn resize_pane_pty_updates_size() {
1560        let mut mgr = new_manager();
1561        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1562
1563        // Default size is 80x24
1564        let (_, _, pane) = mgr.find_pane(&pane_id).unwrap();
1565        assert_eq!(pane.size, (80, 24));
1566
1567        let screen_arc = mgr.resize_pane_pty(&pane_id, 120, 40).unwrap();
1568        // Verify pane size was updated
1569        let (_, _, pane) = mgr.find_pane(&pane_id).unwrap();
1570        assert_eq!(pane.size, (120, 40));
1571
1572        // Verify screen can be resized after dropping manager
1573        {
1574            let mut screen = screen_arc.write().await;
1575            screen.resize(120, 40);
1576            assert_eq!(screen.size(), (120, 40));
1577        }
1578    }
1579
1580    #[tokio::test]
1581    async fn resize_nonexistent_pane_errors() {
1582        let mut mgr = new_manager();
1583        let result = mgr.resize_pane_pty("%99", 80, 24);
1584        assert!(result.is_err());
1585        assert!(result.err().unwrap().to_string().contains("pane not found"));
1586    }
1587
1588    #[tokio::test]
1589    async fn resize_exited_pane_errors() {
1590        let mut mgr = new_manager();
1591        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1592
1593        let pane = mgr.find_pane_mut(&pane_id).unwrap();
1594        pane.process.running = false;
1595        pane.process.exit_code = Some(0);
1596
1597        let result = mgr.resize_pane_pty(&pane_id, 80, 24);
1598        assert!(result.is_err());
1599        assert!(result.err().unwrap().to_string().contains("has exited"));
1600    }
1601
1602    #[tokio::test]
1603    async fn resize_out_of_range_dimensions_errors() {
1604        let mut mgr = new_manager();
1605        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1606
1607        // Too small
1608        let result = mgr.resize_pane_pty(&pane_id, 0, 0);
1609        assert!(result.is_err());
1610        assert!(result.err().unwrap().to_string().contains("too small"));
1611
1612        let result = mgr.resize_pane_pty(&pane_id, 1, 1);
1613        assert!(result.is_err());
1614        assert!(result.err().unwrap().to_string().contains("too small"));
1615
1616        // Too large
1617        let result = mgr.resize_pane_pty(&pane_id, 1001, 24);
1618        assert!(result.is_err());
1619        assert!(result.err().unwrap().to_string().contains("too large"));
1620
1621        let result = mgr.resize_pane_pty(&pane_id, 80, 1001);
1622        assert!(result.is_err());
1623        assert!(result.err().unwrap().to_string().contains("too large"));
1624    }
1625
1626    #[tokio::test]
1627    async fn new_pane_inherits_session_scrollback_limit() {
1628        let mut mgr = new_manager();
1629        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1630
1631        // Default scrollback limit is 2000
1632        {
1633            let (_, _, pane) = mgr.find_pane(&pane0).unwrap();
1634            let screen = pane.screen.read().await;
1635            assert_eq!(screen.scrollback_limit(), 2000);
1636        }
1637
1638        // Change session scrollback limit
1639        let session = mgr.get_session_mut("s1").unwrap();
1640        session.options.scrollback_limit = 500;
1641
1642        // New pane should inherit the updated limit
1643        let pane1 = mgr.split_pane(&pane0, true, None).unwrap();
1644        {
1645            let (_, _, pane) = mgr.find_pane(&pane1).unwrap();
1646            let screen = pane.screen.read().await;
1647            assert_eq!(screen.scrollback_limit(), 500);
1648        }
1649    }
1650
1651    #[tokio::test]
1652    async fn session_scrollback_limit_does_not_affect_existing_panes() {
1653        let mut mgr = new_manager();
1654        let (_, pane0) = mgr.new_session("s1", None).unwrap();
1655
1656        // Change session scrollback limit after pane creation
1657        let session = mgr.get_session_mut("s1").unwrap();
1658        session.options.scrollback_limit = 100;
1659
1660        // Existing pane should still have the original limit
1661        {
1662            let (_, _, pane) = mgr.find_pane(&pane0).unwrap();
1663            let screen = pane.screen.read().await;
1664            assert_eq!(screen.scrollback_limit(), 2000);
1665        }
1666    }
1667
1668    #[tokio::test]
1669    async fn pane_index_consistent_after_operations() {
1670        let mut mgr = new_manager();
1671        let (_, s1_p0) = mgr.new_session("s1", None).unwrap();
1672        let (_, s2_p0) = mgr.new_session("s2", None).unwrap();
1673        let s1_p1 = mgr.split_pane(&s1_p0, true, None).unwrap();
1674        let s2_p1 = mgr.split_pane(&s2_p0, true, None).unwrap();
1675
1676        // All four panes should be findable
1677        assert!(mgr.find_pane(&s1_p0).is_some());
1678        assert!(mgr.find_pane(&s1_p1).is_some());
1679        assert!(mgr.find_pane(&s2_p0).is_some());
1680        assert!(mgr.find_pane(&s2_p1).is_some());
1681
1682        // Kill some panes
1683        mgr.kill_pane(&s1_p1).unwrap();
1684        mgr.kill_pane(&s2_p0).unwrap();
1685
1686        // Killed panes gone, survivors still accessible
1687        assert!(mgr.find_pane(&s1_p1).is_none());
1688        assert!(mgr.find_pane(&s2_p0).is_none());
1689        assert!(mgr.find_pane(&s1_p0).is_some());
1690        assert!(mgr.find_pane(&s2_p1).is_some());
1691    }
1692
1693    #[tokio::test]
1694    async fn pane_index_after_window_kill() {
1695        let mut mgr = new_manager();
1696        let (_, p0) = mgr.new_session("s1", None).unwrap(); // window 0
1697        let (_, p1) = mgr.new_window("s1", None).unwrap(); // window 1
1698
1699        // Both panes accessible
1700        assert!(mgr.find_pane(&p0).is_some());
1701        assert!(mgr.find_pane(&p1).is_some());
1702
1703        // Kill window 0
1704        mgr.kill_window("s1", 0).unwrap();
1705
1706        // p0 gone, p1 survives
1707        assert!(mgr.find_pane(&p0).is_none());
1708        assert!(mgr.find_pane(&p1).is_some());
1709    }
1710
1711    #[tokio::test]
1712    async fn pane_index_after_session_kill() {
1713        let mut mgr = new_manager();
1714        let (_, s1_p0) = mgr.new_session("s1", None).unwrap();
1715        let s1_p1 = mgr.split_pane(&s1_p0, true, None).unwrap();
1716        let (_, s2_p0) = mgr.new_session("s2", None).unwrap();
1717
1718        // All panes accessible
1719        assert!(mgr.find_pane(&s1_p0).is_some());
1720        assert!(mgr.find_pane(&s1_p1).is_some());
1721        assert!(mgr.find_pane(&s2_p0).is_some());
1722
1723        // Kill session s1
1724        mgr.kill_session("s1").unwrap();
1725
1726        // s1 panes gone, s2 pane survives
1727        assert!(mgr.find_pane(&s1_p0).is_none());
1728        assert!(mgr.find_pane(&s1_p1).is_none());
1729        assert!(mgr.find_pane(&s2_p0).is_some());
1730    }
1731
1732    // -- Pane marks tests --
1733
1734    #[tokio::test]
1735    async fn set_mark_and_resolve() {
1736        let mut mgr = new_manager();
1737        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1738        mgr.set_mark("build", &pane_id).unwrap();
1739
1740        let resolved = mgr.resolve_pane_target("@build").unwrap();
1741        assert_eq!(resolved, pane_id);
1742    }
1743
1744    #[tokio::test]
1745    async fn resolve_passthrough() {
1746        let mgr = new_manager();
1747        let resolved = mgr.resolve_pane_target("%0").unwrap();
1748        assert_eq!(resolved, "%0");
1749    }
1750
1751    #[tokio::test]
1752    async fn resolve_unknown_mark_errors() {
1753        let mgr = new_manager();
1754        let err = mgr.resolve_pane_target("@nonexistent").unwrap_err();
1755        assert!(err.to_string().contains("mark not found"));
1756    }
1757
1758    #[tokio::test]
1759    async fn validate_mark_name_rules() {
1760        use super::validate_mark_name;
1761
1762        // Valid names
1763        validate_mark_name("build").unwrap();
1764        validate_mark_name("my-pane").unwrap();
1765        validate_mark_name("test_1").unwrap();
1766        validate_mark_name("A").unwrap();
1767        validate_mark_name("a".repeat(32).as_str()).unwrap();
1768
1769        // Empty
1770        assert!(validate_mark_name("").is_err());
1771        // Too long
1772        assert!(validate_mark_name(&"a".repeat(33)).is_err());
1773        // Invalid chars
1774        assert!(validate_mark_name("hello world").is_err());
1775        assert!(validate_mark_name("foo@bar").is_err());
1776        assert!(validate_mark_name("a.b").is_err());
1777    }
1778
1779    #[tokio::test]
1780    async fn reassign_mark() {
1781        let mut mgr = new_manager();
1782        let (_, p0) = mgr.new_session("s1", None).unwrap();
1783        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1784
1785        mgr.set_mark("target", &p0).unwrap();
1786        assert_eq!(mgr.resolve_pane_target("@target").unwrap(), p0);
1787
1788        // Reassign to different pane
1789        mgr.set_mark("target", &p1).unwrap();
1790        assert_eq!(mgr.resolve_pane_target("@target").unwrap(), p1);
1791    }
1792
1793    #[tokio::test]
1794    async fn multiple_marks_per_pane() {
1795        let mut mgr = new_manager();
1796        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1797
1798        mgr.set_mark("build", &pane_id).unwrap();
1799        mgr.set_mark("main", &pane_id).unwrap();
1800        mgr.set_mark("dev", &pane_id).unwrap();
1801
1802        assert_eq!(mgr.resolve_pane_target("@build").unwrap(), pane_id);
1803        assert_eq!(mgr.resolve_pane_target("@main").unwrap(), pane_id);
1804        assert_eq!(mgr.resolve_pane_target("@dev").unwrap(), pane_id);
1805
1806        let mut marks = mgr.marks_for_pane(&pane_id);
1807        marks.sort();
1808        assert_eq!(marks, vec!["build", "dev", "main"]);
1809    }
1810
1811    #[tokio::test]
1812    async fn kill_pane_cleans_marks() {
1813        let mut mgr = new_manager();
1814        let (_, p0) = mgr.new_session("s1", None).unwrap();
1815        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1816
1817        mgr.set_mark("a", &p0).unwrap();
1818        mgr.set_mark("b", &p1).unwrap();
1819
1820        mgr.kill_pane(&p0).unwrap();
1821        assert!(mgr.resolve_pane_target("@a").is_err());
1822        // Mark on surviving pane remains
1823        assert_eq!(mgr.resolve_pane_target("@b").unwrap(), p1);
1824    }
1825
1826    #[tokio::test]
1827    async fn kill_session_cleans_marks() {
1828        let mut mgr = new_manager();
1829        let (_, s1_p0) = mgr.new_session("s1", None).unwrap();
1830        let s1_p1 = mgr.split_pane(&s1_p0, true, None).unwrap();
1831        let (_, s2_p0) = mgr.new_session("s2", None).unwrap();
1832
1833        mgr.set_mark("a", &s1_p0).unwrap();
1834        mgr.set_mark("b", &s1_p1).unwrap();
1835        mgr.set_mark("c", &s2_p0).unwrap();
1836
1837        mgr.kill_session("s1").unwrap();
1838        assert!(mgr.resolve_pane_target("@a").is_err());
1839        assert!(mgr.resolve_pane_target("@b").is_err());
1840        assert_eq!(mgr.resolve_pane_target("@c").unwrap(), s2_p0);
1841    }
1842
1843    #[tokio::test]
1844    async fn kill_window_cleans_marks() {
1845        let mut mgr = new_manager();
1846        let (_, p0) = mgr.new_session("s1", None).unwrap();
1847        let (_, p1) = mgr.new_window("s1", None).unwrap();
1848
1849        mgr.set_mark("win0", &p0).unwrap();
1850        mgr.set_mark("win1", &p1).unwrap();
1851
1852        mgr.kill_window("s1", 0).unwrap();
1853        assert!(mgr.resolve_pane_target("@win0").is_err());
1854        assert_eq!(mgr.resolve_pane_target("@win1").unwrap(), p1);
1855    }
1856
1857    #[tokio::test]
1858    async fn list_marks_returns_all() {
1859        let mut mgr = new_manager();
1860        let (_, p0) = mgr.new_session("s1", None).unwrap();
1861        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1862
1863        mgr.set_mark("alpha", &p0).unwrap();
1864        mgr.set_mark("beta", &p1).unwrap();
1865
1866        let mut marks = mgr.list_marks();
1867        marks.sort_by(|a, b| a.0.cmp(&b.0));
1868        assert_eq!(marks.len(), 2);
1869        assert_eq!(marks[0], ("alpha".to_string(), p0));
1870        assert_eq!(marks[1], ("beta".to_string(), p1));
1871    }
1872
1873    #[tokio::test]
1874    async fn remove_mark() {
1875        let mut mgr = new_manager();
1876        let (_, pane_id) = mgr.new_session("s1", None).unwrap();
1877        mgr.set_mark("temp", &pane_id).unwrap();
1878        mgr.remove_mark("temp").unwrap();
1879        assert!(mgr.resolve_pane_target("@temp").is_err());
1880    }
1881
1882    #[tokio::test]
1883    async fn remove_nonexistent_mark_errors() {
1884        let mut mgr = new_manager();
1885        let err = mgr.remove_mark("nope").unwrap_err();
1886        assert!(err.to_string().contains("mark not found"));
1887    }
1888
1889    #[tokio::test]
1890    async fn set_mark_nonexistent_pane_errors() {
1891        let mut mgr = new_manager();
1892        mgr.new_session("s1", None).unwrap();
1893        let err = mgr.set_mark("x", "%99").unwrap_err();
1894        assert!(err.to_string().contains("pane not found"));
1895    }
1896
1897    #[tokio::test]
1898    async fn inject_marks_populates_pane_info() {
1899        let mut mgr = new_manager();
1900        let (_, p0) = mgr.new_session("s1", None).unwrap();
1901        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1902
1903        mgr.set_mark("build", &p0).unwrap();
1904        mgr.set_mark("test", &p1).unwrap();
1905        mgr.set_mark("main", &p0).unwrap();
1906
1907        let panes = mgr.list_panes("s1").unwrap();
1908        let p0_info = panes.iter().find(|p| p.id == p0).unwrap();
1909        let p1_info = panes.iter().find(|p| p.id == p1).unwrap();
1910
1911        let mut p0_marks = p0_info.marks.clone();
1912        p0_marks.sort();
1913        assert_eq!(p0_marks, vec!["build", "main"]);
1914        assert_eq!(p1_info.marks, vec!["test"]);
1915    }
1916
1917    // -- swap_panes tests --
1918
1919    #[tokio::test]
1920    async fn swap_panes_basic() {
1921        let mut mgr = new_manager();
1922        let (_, p0) = mgr.new_session("s1", None).unwrap();
1923        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1924
1925        // Before swap: panes order is [p0, p1]
1926        let panes_before = mgr.list_panes("s1").unwrap();
1927        assert_eq!(panes_before[0].id, p0);
1928        assert_eq!(panes_before[1].id, p1);
1929
1930        mgr.swap_panes(&p0, &p1).unwrap();
1931
1932        // After swap: panes order is [p1, p0]
1933        let panes_after = mgr.list_panes("s1").unwrap();
1934        assert_eq!(panes_after[0].id, p1);
1935        assert_eq!(panes_after[1].id, p0);
1936    }
1937
1938    #[tokio::test]
1939    async fn swap_panes_active_tracking() {
1940        let mut mgr = new_manager();
1941        let (_, p0) = mgr.new_session("s1", None).unwrap();
1942        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1943
1944        // Active pane is p0 at index 0
1945        mgr.select_pane(&p0).unwrap();
1946
1947        mgr.swap_panes(&p0, &p1).unwrap();
1948
1949        // After swap, active should follow p0 to index 1
1950        let session = mgr.get_session("s1").unwrap();
1951        let window = &session.windows[0];
1952        assert_eq!(window.panes[window.active_pane].id, p0);
1953    }
1954
1955    #[tokio::test]
1956    async fn swap_panes_noop() {
1957        let mut mgr = new_manager();
1958        let (_, p0) = mgr.new_session("s1", None).unwrap();
1959        mgr.split_pane(&p0, true, None).unwrap();
1960
1961        // Swapping a pane with itself is a no-op
1962        mgr.swap_panes(&p0, &p0).unwrap();
1963
1964        let panes = mgr.list_panes("s1").unwrap();
1965        assert_eq!(panes[0].id, p0);
1966    }
1967
1968    #[tokio::test]
1969    async fn swap_panes_cross_window_error() {
1970        let mut mgr = new_manager();
1971        let (_, p0) = mgr.new_session("s1", None).unwrap();
1972        let (_, p1) = mgr.new_window("s1", None).unwrap();
1973
1974        let err = mgr.swap_panes(&p0, &p1).unwrap_err();
1975        assert!(err.to_string().contains("same window"));
1976    }
1977
1978    #[tokio::test]
1979    async fn swap_panes_not_found() {
1980        let mut mgr = new_manager();
1981        let (_, p0) = mgr.new_session("s1", None).unwrap();
1982
1983        let err = mgr.swap_panes(&p0, "%99").unwrap_err();
1984        assert!(err.to_string().contains("pane not found"));
1985
1986        let err = mgr.swap_panes("%99", &p0).unwrap_err();
1987        assert!(err.to_string().contains("pane not found"));
1988    }
1989
1990    #[tokio::test]
1991    async fn swap_panes_three_panes() {
1992        let mut mgr = new_manager();
1993        let (_, p0) = mgr.new_session("s1", None).unwrap();
1994        let p1 = mgr.split_pane(&p0, true, None).unwrap();
1995        let p2 = mgr.split_pane(&p1, true, None).unwrap();
1996
1997        // Swap first and last, middle unchanged
1998        mgr.swap_panes(&p0, &p2).unwrap();
1999
2000        let panes = mgr.list_panes("s1").unwrap();
2001        assert_eq!(panes[0].id, p2);
2002        assert_eq!(panes[1].id, p1);
2003        assert_eq!(panes[2].id, p0);
2004    }
2005
2006    #[tokio::test]
2007    async fn swap_panes_marks_survive() {
2008        let mut mgr = new_manager();
2009        let (_, p0) = mgr.new_session("s1", None).unwrap();
2010        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2011
2012        mgr.set_mark("build", &p0).unwrap();
2013        mgr.set_mark("test", &p1).unwrap();
2014
2015        mgr.swap_panes(&p0, &p1).unwrap();
2016
2017        // Marks still resolve to same pane IDs
2018        assert_eq!(mgr.resolve_pane_target("@build").unwrap(), p0);
2019        assert_eq!(mgr.resolve_pane_target("@test").unwrap(), p1);
2020    }
2021
2022    // -- move_pane tests --
2023
2024    #[tokio::test]
2025    async fn move_pane_basic() {
2026        let mut mgr = new_manager();
2027        let (_, p0) = mgr.new_session("src", None).unwrap();
2028        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2029        let (_, _p2) = mgr.new_session("dst", None).unwrap();
2030
2031        // dst has window 0
2032        let dst_windows = mgr.list_windows("dst").unwrap();
2033        let dst_win = dst_windows[0].id;
2034
2035        mgr.move_pane(&p1, "dst", dst_win).unwrap();
2036
2037        // p1 should now be in dst
2038        let dst_panes = mgr.list_panes("dst").unwrap();
2039        assert!(dst_panes.iter().any(|p| p.id == p1));
2040
2041        // p1 should be gone from src
2042        let src_panes = mgr.list_panes("src").unwrap();
2043        assert!(!src_panes.iter().any(|p| p.id == p1));
2044    }
2045
2046    #[tokio::test]
2047    async fn move_pane_cascade_kills_window_and_session() {
2048        let mut mgr = new_manager();
2049        // src session with 1 window, 1 pane
2050        let (_, p0) = mgr.new_session("src", None).unwrap();
2051        let (_, _p1) = mgr.new_session("dst", None).unwrap();
2052
2053        let dst_windows = mgr.list_windows("dst").unwrap();
2054        let dst_win = dst_windows[0].id;
2055
2056        // Move the only pane out of src
2057        mgr.move_pane(&p0, "dst", dst_win).unwrap();
2058
2059        // src session should be killed (cascade)
2060        assert!(!mgr.has_session("src"));
2061    }
2062
2063    #[tokio::test]
2064    async fn move_pane_cascade_kills_window_not_session() {
2065        let mut mgr = new_manager();
2066        let (_, p0) = mgr.new_session("src", None).unwrap();
2067        // Add a second window so session survives
2068        let (_, _p_extra) = mgr.new_window("src", None).unwrap();
2069        let (_, _p1) = mgr.new_session("dst", None).unwrap();
2070
2071        let dst_windows = mgr.list_windows("dst").unwrap();
2072        let dst_win = dst_windows[0].id;
2073
2074        // Move p0 out of window 0 — only 1 pane in that window
2075        mgr.move_pane(&p0, "dst", dst_win).unwrap();
2076
2077        // Session survives, but window 0 should be gone
2078        assert!(mgr.has_session("src"));
2079        let src_windows = mgr.list_windows("src").unwrap();
2080        assert_eq!(src_windows.len(), 1);
2081        // The remaining window is window 1 (the second one)
2082        assert!(!src_windows.iter().any(|w| w.id == 0));
2083    }
2084
2085    #[tokio::test]
2086    async fn move_pane_same_window_noop() {
2087        let mut mgr = new_manager();
2088        let (_, p0) = mgr.new_session("s1", None).unwrap();
2089        let _ = mgr.split_pane(&p0, true, None).unwrap();
2090
2091        let windows = mgr.list_windows("s1").unwrap();
2092        let win_id = windows[0].id;
2093
2094        // Move to same window is a no-op
2095        mgr.move_pane(&p0, "s1", win_id).unwrap();
2096
2097        // Still 2 panes in window
2098        let panes = mgr.list_panes("s1").unwrap();
2099        assert_eq!(panes.len(), 2);
2100    }
2101
2102    #[tokio::test]
2103    async fn move_pane_active_pane_update() {
2104        let mut mgr = new_manager();
2105        let (_, p0) = mgr.new_session("src", None).unwrap();
2106        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2107        let (_, _p2) = mgr.new_session("dst", None).unwrap();
2108
2109        let dst_windows = mgr.list_windows("dst").unwrap();
2110        let dst_win = dst_windows[0].id;
2111
2112        // Move p1 to dst
2113        mgr.move_pane(&p1, "dst", dst_win).unwrap();
2114
2115        // Moved pane should be active in target
2116        let dst_session = mgr.get_session("dst").unwrap();
2117        let dst_window = dst_session.find_window(dst_win).unwrap();
2118        assert_eq!(dst_window.panes[dst_window.active_pane].id, p1);
2119    }
2120
2121    #[tokio::test]
2122    async fn move_pane_layout_update() {
2123        let mut mgr = new_manager();
2124        let (_, p0) = mgr.new_session("src", None).unwrap();
2125        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2126        let (_, _p2) = mgr.new_session("dst", None).unwrap();
2127
2128        let dst_windows = mgr.list_windows("dst").unwrap();
2129        let dst_win = dst_windows[0].id;
2130
2131        // Before: src has 2 panes (EvenHorizontal), dst has 1 (Single)
2132        let src_session = mgr.get_session("src").unwrap();
2133        assert_eq!(src_session.windows[0].layout, Layout::EvenHorizontal);
2134        let dst_session = mgr.get_session("dst").unwrap();
2135        let dst_window = dst_session.find_window(dst_win).unwrap();
2136        assert_eq!(dst_window.layout, Layout::Single);
2137
2138        mgr.move_pane(&p1, "dst", dst_win).unwrap();
2139
2140        // After: src has 1 pane (Single), dst has 2 (EvenHorizontal)
2141        let src_session = mgr.get_session("src").unwrap();
2142        assert_eq!(src_session.windows[0].layout, Layout::Single);
2143        let dst_session = mgr.get_session("dst").unwrap();
2144        let dst_window = dst_session.find_window(dst_win).unwrap();
2145        assert_eq!(dst_window.layout, Layout::EvenHorizontal);
2146    }
2147
2148    #[tokio::test]
2149    async fn move_pane_index_updated() {
2150        let mut mgr = new_manager();
2151        let (_, p0) = mgr.new_session("src", None).unwrap();
2152        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2153        let (_, _p2) = mgr.new_session("dst", None).unwrap();
2154
2155        let dst_windows = mgr.list_windows("dst").unwrap();
2156        let dst_win = dst_windows[0].id;
2157
2158        mgr.move_pane(&p1, "dst", dst_win).unwrap();
2159
2160        // find_pane should locate p1 in dst session now
2161        let (session, window, pane) = mgr.find_pane(&p1).unwrap();
2162        assert_eq!(session.name, "dst");
2163        assert_eq!(window.id, dst_win);
2164        assert_eq!(pane.id, p1);
2165    }
2166
2167    #[tokio::test]
2168    async fn move_pane_marks_survive() {
2169        let mut mgr = new_manager();
2170        let (_, p0) = mgr.new_session("src", None).unwrap();
2171        let p1 = mgr.split_pane(&p0, true, None).unwrap();
2172        let (_, _p2) = mgr.new_session("dst", None).unwrap();
2173
2174        mgr.set_mark("build", &p1).unwrap();
2175
2176        let dst_windows = mgr.list_windows("dst").unwrap();
2177        let dst_win = dst_windows[0].id;
2178
2179        mgr.move_pane(&p1, "dst", dst_win).unwrap();
2180
2181        // Mark still resolves
2182        assert_eq!(mgr.resolve_pane_target("@build").unwrap(), p1);
2183    }
2184
2185    #[tokio::test]
2186    async fn move_pane_not_found() {
2187        let mut mgr = new_manager();
2188        let (_, _p0) = mgr.new_session("s1", None).unwrap();
2189
2190        let err = mgr.move_pane("%99", "s1", 0).unwrap_err();
2191        assert!(err.to_string().contains("pane not found"));
2192    }
2193
2194    #[tokio::test]
2195    async fn move_pane_target_session_not_found() {
2196        let mut mgr = new_manager();
2197        let (_, p0) = mgr.new_session("s1", None).unwrap();
2198
2199        let err = mgr.move_pane(&p0, "nonexistent", 0).unwrap_err();
2200        assert!(err.to_string().contains("session not found"));
2201    }
2202
2203    #[tokio::test]
2204    async fn move_pane_target_window_not_found() {
2205        let mut mgr = new_manager();
2206        let (_, p0) = mgr.new_session("s1", None).unwrap();
2207        let (_, _p1) = mgr.new_session("dst", None).unwrap();
2208
2209        let err = mgr.move_pane(&p0, "dst", 99).unwrap_err();
2210        assert!(err.to_string().contains("window not found"));
2211    }
2212
2213    #[tokio::test]
2214    async fn server_status_counts() {
2215        let mut mgr = new_manager();
2216
2217        // Empty server
2218        let status = mgr.server_status();
2219        assert_eq!(status.sessions, 0);
2220        assert_eq!(status.windows, 0);
2221        assert_eq!(status.panes, 0);
2222
2223        // Create sessions and panes
2224        let (_, p0) = mgr.new_session("s1", None).unwrap();
2225        mgr.split_pane(&p0, true, None).unwrap();
2226        mgr.new_session("s2", None).unwrap();
2227        mgr.new_window("s2", None).unwrap();
2228
2229        let status = mgr.server_status();
2230        assert_eq!(status.sessions, 2);
2231        assert_eq!(status.windows, 3); // s1:1 window + s2:2 windows
2232        assert_eq!(status.panes, 4);   // s1:2 panes + s2:2 panes
2233
2234        // uptime should be very small
2235        assert!(status.uptime_secs < 5);
2236    }
2237}