diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c94bba..95c2ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added recursive split panes: any pane can be split side by side (`alt-\`) or top/bottom (`alt--`), repeatedly and in any direction, for arbitrary tiled layouts. `alt-w` closes the focused pane (its sibling reclaims the space), `alt-r` rotates a split, `alt-o` cycles focus. Replaces the previous two-pane toggle; keybinding actions are now `split_pane_right`, `split_pane_down`, `close_pane`, `rotate_split`, `focus_next_pane`. +- Added screen-style multi-session support: each `linkshell` starts its own detached server, `linkshell ls` lists live sessions (id, name, pid, status), and `linkshell -r ` reattaches to a specific one. `linkshell new [name]` names a session; `linkshell -r` with no id attaches the sole running session. - Fixed 100% CPU usage caused by full-screen agent TUIs (notably OpenCode) that repaint continuously: session output now triggers a redraw only when a visible session's screen actually changes, and the partial-line heartbeat only when a session's inferred state changes. - Added startup profiles and live profile saving. - Added split panes with independent focus and PTY sizing. diff --git a/README.md b/README.md index 6327a8b..b1f3ec3 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ tmux doesn't know your Claude session is blocked waiting on you. It doesn't know ## Features - **Up to 8 sessions** — Claude, Codex, shell, or any custom command -- **Detach & reattach** — tmux-style client/server split; quit the client and sessions keep running (`alt-d` to detach, `linkshell` or `linkshell -r` to reattach) -- **Split panes** — view two sessions side by side (`alt-\` to split, `alt--` to rotate, `alt-o` to switch focus) +- **Detach, reattach & multiple sessions** — tmux/screen-style client/server split; quit the client and sessions keep running. Run many independent linkshells (`linkshell ls` to list, `linkshell -r ` to reattach) +- **Recursive split panes** — split any pane side by side (`alt-\`) or top/bottom (`alt--`), repeatedly and in any direction, for arbitrary tiled layouts; `alt-w` closes a pane, `alt-r` rotates a split, `alt-o` switches focus - **Startup profiles** — save a layout of sessions and pipes with `profile save `, relaunch it with `--profile ` - **Aliased identities** — env-prefixed commands (`CLAUDE_CONFIG_DIR=~/w claude`) and configured wrapper aliases get full Claude/Codex treatment, each with its own config home - **Local agents & LLMs** — opencode, oh-my-pi, pi, aider, and llama.cpp sessions get agent-style state inference; llama.cpp/Ollama/vLLM/LM Studio endpoints are chat-addressable via `[agents.*]` @@ -78,7 +78,9 @@ Requires Rust 1.80+. ## Usage ```bash -linkshell # Unix socket at $XDG_RUNTIME_DIR/linkshell/.sock +linkshell # start a new session (Unix socket at $XDG_RUNTIME_DIR/linkshell/.sock) +linkshell ls # list detached sessions +linkshell -r # reattach to a detached session linkshell --tcp # also open TCP agent listener on port 7373 linkshell --tcp 9000 # custom TCP port linkshell --council examples/council.toml # launch a multi-agent council @@ -87,10 +89,22 @@ linkshell --profile ucc-dev # launch a named session profile Then create your first session with `alt-n`. -Linkshell runs as a client/server pair, like tmux: the first `linkshell` -starts a background server that owns the sessions, and the foreground TUI is -a client attached to it. `alt-d` detaches — sessions keep running — and -running `linkshell` (or `linkshell -r`) again reattaches to the live server. +Linkshell runs as a client/server pair, like tmux/screen: each `linkshell` +starts a background server that owns its sessions, and the foreground TUI is +a client attached to it. `alt-d` detaches — sessions keep running. + +You can run **multiple independent linkshells** on one machine, screen-style: + +```bash +linkshell # start a new detached server and attach +linkshell new work # start a new one named "work" +linkshell ls # list detached sessions (id, name, pid, status) +linkshell -r # reattach to a specific session by id +linkshell -r # reattach when exactly one session is running +``` + +Each server has its own id, pid, and sockets; `linkshell ls` prunes any whose +process has died. If something looks wrong (missing logs, stale socket, nested multiplexer, limited terminal colors), run `linkshell doctor` for a diagnostic report. @@ -316,8 +330,10 @@ returns you to the live tail. | `alt-h` | Toggle help | | `alt-x` | Kill active session | | `alt-d` | Detach (sessions keep running) | -| `alt-\` | Toggle split pane | -| `alt--` | Rotate split layout | +| `alt-\` | Split focused pane side by side | +| `alt--` | Split focused pane top/bottom | +| `alt-w` | Close focused pane (sibling reclaims the space) | +| `alt-r` | Rotate the focused pane's split direction | | `alt-o` | Focus next pane | | `alt-b` | Toggle broadcast input to all sessions | | `alt-g` | Dock the chat pane | diff --git a/src/app.rs b/src/app.rs index 891b3e1..900976a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,10 @@ use crate::config::{self, Config}; use crate::council::CouncilRouter; use crate::events::AppEvent; use crate::keybindings::{self, Keymap}; +use crate::layout::{LayoutTree, SplitDir}; + +/// Maximum number of simultaneously visible panes. +pub const MAX_PANES: usize = 8; use crate::patterns::PatternMatcher; use crate::pipe::{self, ExtractMode, Pipe, PipeTrigger}; use crate::session::{ @@ -403,21 +407,14 @@ impl FileBrowserState { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LayoutMode { - Single, - /// Two panes side by side (vertical divider). - SplitV, - /// Two panes stacked (horizontal divider). - SplitH, -} - pub struct App { pub sessions: Vec, - pub layout: LayoutMode, - /// Split direction restored by toggle_split after a collapse to Single. - pub last_split: LayoutMode, - pub panes: [Option; 2], + /// Geometry of the output zone: a binary tree of splits whose leaves map + /// one-to-one, in in-order traversal order, to `panes` and `pane_sizes`. + pub tree: LayoutTree, + /// One entry per pane slot (leaf), holding the index into `sessions` shown + /// there (or `None` for an empty pane). Length matches the tree's leaves. + pub panes: Vec>, pub focused_pane: usize, pub mode: AppMode, pub new_session_state: NewSessionState, @@ -431,8 +428,9 @@ pub struct App { pub event_tx: mpsc::Sender, pub config: Arc, pub pipes: Vec, - // Current PTY size derived from the output pane (rows, cols) - pub pane_sizes: [(u16, u16); 2], + // Current PTY size derived from each output pane (rows, cols), one per + // pane slot; length matches `panes`. + pub pane_sizes: Vec<(u16, u16)>, // Layout cache (updated after each draw, used for mouse hit-testing) pub output_areas: Vec, pub session_bar_area: Rect, @@ -535,9 +533,8 @@ impl App { } Self { sessions: Vec::new(), - layout: LayoutMode::Single, - last_split: LayoutMode::SplitV, - panes: [None, None], + tree: LayoutTree::Leaf, + panes: vec![None], focused_pane: 0, mode: AppMode::Normal, new_session_state: NewSessionState::default(), @@ -551,7 +548,7 @@ impl App { event_tx, config, pipes: Vec::new(), - pane_sizes: [(PTY_ROWS, PTY_COLS); 2], + pane_sizes: vec![(PTY_ROWS, PTY_COLS)], output_areas: Vec::new(), session_bar_area: Rect::default(), session_slot_areas: Vec::new(), @@ -608,7 +605,7 @@ impl App { } pub fn active_idx(&self) -> Option { - self.panes[self.focused_pane] + self.panes.get(self.focused_pane).copied().flatten() } /// Indices into `sessions` of the sessions the user can see and switch @@ -1015,8 +1012,7 @@ impl App { other => other, }; } - if self.layout == LayoutMode::Single && self.panes[0].is_none() && !self.sessions.is_empty() - { + if !self.is_split() && self.panes[0].is_none() && !self.sessions.is_empty() { // Fall back to the nearest visible session, if any remain. let target = idx.min(self.sessions.len() - 1); self.panes[0] = self @@ -1032,11 +1028,13 @@ impl App { if self.chat_docked == Some(self.focused_pane) { self.chat_docked = None; } - let other = self.focused_pane ^ 1; - if idx < self.sessions.len() - && !self.sessions[idx].hidden - && (!self.is_split() || self.panes[other] != Some(idx)) - { + // Don't show the same session in two panes at once. + let shown_elsewhere = self + .panes + .iter() + .enumerate() + .any(|(i, p)| i != self.focused_pane && *p == Some(idx)); + if idx < self.sessions.len() && !self.sessions[idx].hidden && !shown_elsewhere { self.panes[self.focused_pane] = Some(idx); let (rows, cols) = self.pane_sizes[self.focused_pane]; let session = &mut self.sessions[idx]; @@ -1082,50 +1080,75 @@ impl App { } pub fn is_split(&self) -> bool { - self.layout != LayoutMode::Single - } - - pub fn toggle_split(&mut self) { - match self.layout { - LayoutMode::Single => { - // Reopen in whichever direction was used last (default SplitV). - self.layout = self.last_split; - if self.panes[1].is_none() { - self.panes[1] = self - .visible_indices() - .into_iter() - .find(|idx| Some(*idx) != self.panes[0]); - } - } - LayoutMode::SplitV | LayoutMode::SplitH => { - self.last_split = self.layout; + self.panes.len() > 1 + } + + /// Split the focused pane in two, creating a new empty pane beside (Row) or + /// below (Col) it, and move focus to the new pane. The new pane is seeded + /// with the next visible session not already on screen, if any. + pub fn split_focused(&mut self, dir: SplitDir) { + if self.panes.len() >= MAX_PANES { + return; + } + self.tree.split_leaf(self.focused_pane, dir); + let new_slot = self.focused_pane + 1; + // Seed the new pane with a session that isn't already displayed. + let seed = self + .visible_indices() + .into_iter() + .find(|idx| !self.panes.contains(&Some(*idx))); + let size = self.pane_sizes[self.focused_pane]; + self.panes.insert(new_slot, seed); + self.pane_sizes.insert(new_slot, size); + // A pane inserted at or before a docked-chat slot shifts its index. + if let Some(dock) = self.chat_docked.as_mut() { + if *dock >= new_slot { + *dock += 1; + } + } + self.focused_pane = new_slot; + // No immediate PTY resize: the true pane geometry isn't known until the + // next draw, whose post-draw handle_pane_resize sizes the new pane's + // session correctly. + self.needs_redraw = true; + } + + /// Close the focused pane; its sibling reclaims the space. The session in + /// the pane is unassigned, not killed. No-op when only one pane remains. + pub fn close_focused_pane(&mut self) { + if self.panes.len() <= 1 { + return; + } + if !self.tree.close_leaf(self.focused_pane) { + return; + } + let removed = self.focused_pane; + self.panes.remove(removed); + self.pane_sizes.remove(removed); + // Keep the docked-chat slot pointing at the same pane. + if let Some(dock) = self.chat_docked { + if dock == removed { self.chat_docked = None; - self.panes[0] = self.active_idx(); - self.panes[1] = None; - self.focused_pane = 0; - self.layout = LayoutMode::Single; + } else if dock > removed { + self.chat_docked = Some(dock - 1); } } + self.focused_pane = removed.min(self.panes.len() - 1); self.needs_redraw = true; } - /// Flip an active split between side-by-side (SplitV) and stacked - /// (SplitH). No-op in Single layout — the pane pair and focus are - /// preserved; only the divider direction changes. The post-draw - /// handle_pane_resize pass propagates the new geometry to session PTYs. + /// Flip the split that parents the focused pane between side-by-side and + /// stacked. The post-draw handle_pane_resize pass propagates the new + /// geometry to session PTYs. pub fn rotate_split(&mut self) { - match self.layout { - LayoutMode::SplitV => self.layout = LayoutMode::SplitH, - LayoutMode::SplitH => self.layout = LayoutMode::SplitV, - LayoutMode::Single => return, + if self.tree.rotate_leaf(self.focused_pane) { + self.needs_redraw = true; } - self.last_split = self.layout; - self.needs_redraw = true; } pub fn focus_next_pane(&mut self) { if self.is_split() { - self.focused_pane ^= 1; + self.focused_pane = (self.focused_pane + 1) % self.panes.len(); self.needs_redraw = true; } } @@ -1196,15 +1219,13 @@ impl App { } /// Called from the main loop after each draw when the output area changes size. - pub fn handle_pane_resize(&mut self, sizes: [(u16, u16); 2]) { + pub fn handle_pane_resize(&mut self, sizes: &[(u16, u16)]) { let mut changed = false; - let mut visible: [Option; 2] = [None, None]; - for (pane_idx, size) in sizes.iter().copied().enumerate() { - if self.layout == LayoutMode::Single && pane_idx == 1 { - continue; + for (pane_idx, &size) in sizes.iter().enumerate() { + if pane_idx >= self.pane_sizes.len() { + break; } - visible[pane_idx] = self.panes[pane_idx]; - if self.pane_sizes[pane_idx] == sizes[pane_idx] { + if self.pane_sizes[pane_idx] == size { continue; } self.pane_sizes[pane_idx] = size; @@ -1230,7 +1251,7 @@ impl App { // they'd appear in when switched to — the focused one. let (rows, cols) = self.pane_sizes[self.focused_pane]; for (idx, session) in self.sessions.iter_mut().enumerate() { - if visible.contains(&Some(idx)) { + if self.panes.contains(&Some(idx)) { continue; } session.resize_screen(rows, cols); @@ -1242,7 +1263,11 @@ impl App { #[cfg_attr(not(test), allow(dead_code))] pub fn handle_resize(&mut self, rows: u16, cols: u16) { - self.handle_pane_resize([(rows, cols), self.pane_sizes[1]]); + let mut sizes = self.pane_sizes.clone(); + if let Some(first) = sizes.first_mut() { + *first = (rows, cols); + } + self.handle_pane_resize(&sizes); } /// Returns whether this output warrants a redraw: only when the byte chunk @@ -3522,13 +3547,12 @@ impl App { ["chat", "dock"] => self.dock_chat(None), ["chat", "dock", side] => match *side { "left" | "right" | "top" | "bottom" => { - self.layout = if matches!(*side, "left" | "right") { - LayoutMode::SplitV + let dir = if matches!(*side, "left" | "right") { + SplitDir::Row } else { - LayoutMode::SplitH + SplitDir::Col }; - self.last_split = self.layout; - self.dock_chat(Some(usize::from(matches!(*side, "right" | "bottom")))); + self.dock_chat(Some(dir)); } other => { self.command_result = format!( @@ -4240,37 +4264,41 @@ impl App { }; } - /// Dock the chat into a split pane. `pane` picks the slot (0 = left/top, - /// 1 = right/bottom); None docks into the non-focused pane. Opens a split - /// if the layout is Single and moves focus to the chat. - pub fn dock_chat(&mut self, pane: Option) { + /// Dock the chat into a dedicated pane, splitting the focused pane to make + /// room. `dir` picks the split direction (default side-by-side). Focus + /// moves to the chat pane. + pub fn dock_chat(&mut self, dir: Option) { if matches!(self.mode, AppMode::Chat) { self.mode = AppMode::Normal; } - if !self.is_split() { - self.layout = self.last_split; + if let Some(pane) = self.chat_docked { + // Already docked — just focus it. + self.focused_pane = pane; + self.needs_redraw = true; + return; } - let pane = pane.unwrap_or(self.focused_pane ^ 1).min(1); - self.chat_docked = Some(pane); - self.focused_pane = pane; + let panes_before = self.panes.len(); + self.split_focused(dir.unwrap_or(SplitDir::Row)); + if self.panes.len() == panes_before { + // Couldn't split (pane cap reached) — nothing to dock into. + return; + } + // The freshly created, now-focused pane hosts the chat instead of a + // session. + self.panes[self.focused_pane] = None; + self.chat_docked = Some(self.focused_pane); self.chat_selection = None; self.needs_redraw = true; } - /// Remove the chat from its split pane. The session behind it (if any) - /// becomes visible again; an empty pane collapses the split. + /// Remove the chat from its pane; the sibling pane reclaims the space. pub fn undock_chat(&mut self) { let Some(pane) = self.chat_docked.take() else { return; }; self.chat_selection = None; - if self.panes[pane].is_none() { - self.panes[0] = self.panes[pane ^ 1]; - self.panes[1] = None; - self.last_split = self.layout; - self.layout = LayoutMode::Single; - self.focused_pane = 0; - } + self.focused_pane = pane; + self.close_focused_pane(); self.needs_redraw = true; } @@ -6248,84 +6276,90 @@ mod tests { app.spawn_headless_session("two".into(), None).unwrap(); app.spawn_headless_session("three".into(), None).unwrap(); - app.toggle_split(); - assert_eq!(app.layout, LayoutMode::SplitV); - assert_eq!(app.panes, [Some(0), Some(1)]); - assert_eq!(app.active_idx(), Some(0)); + app.split_focused(SplitDir::Row); + assert_eq!(app.panes, vec![Some(0), Some(1)]); + assert_eq!(app.focused_pane, 1, "focus moves to the new pane"); + assert_eq!(app.active_idx(), Some(1)); app.focus_next_pane(); - assert_eq!(app.focused_pane, 1); - assert_eq!(app.active_idx(), Some(1)); + assert_eq!(app.focused_pane, 0); + assert_eq!(app.active_idx(), Some(0)); + app.focus_next_pane(); app.switch_to(2); - assert_eq!(app.panes, [Some(0), Some(2)]); + assert_eq!(app.panes, vec![Some(0), Some(2)]); app.switch_to(0); assert_eq!( app.panes, - [Some(0), Some(2)], + vec![Some(0), Some(2)], "a session cannot be displayed in both panes" ); } #[test] - fn rotate_split_flips_direction_and_toggle_remembers_it() { + fn rotate_split_flips_the_parent_split_direction() { let mut app = make_app(); app.spawn_headless_session("one".into(), None).unwrap(); app.spawn_headless_session("two".into(), None).unwrap(); - app.rotate_split(); + app.rotate_split(); // one pane: no split to rotate + assert_eq!(app.tree, LayoutTree::Leaf); + + app.split_focused(SplitDir::Row); + let area = Rect::new(0, 0, 100, 40); + let before = app.tree.rects(area); assert_eq!( - app.layout, - LayoutMode::Single, - "rotate is a no-op in Single" + before[1].x, + before[0].x + before[0].width, + "Row split places panes side by side" ); - app.toggle_split(); - assert_eq!(app.layout, LayoutMode::SplitV); - let panes = app.panes; - app.focus_next_pane(); - app.rotate_split(); - assert_eq!(app.layout, LayoutMode::SplitH); - assert_eq!(app.panes, panes, "rotation preserves the pane pair"); + let after = app.tree.rects(area); + assert_eq!( + after[1].y, + after[0].y + after[0].height, + "rotation stacks the panes" + ); + assert_eq!( + app.panes, + vec![Some(0), Some(1)], + "rotation keeps the panes" + ); assert_eq!(app.focused_pane, 1, "rotation preserves focus"); - - app.focus_next_pane(); - assert_eq!(app.focused_pane, 0, "pane focus works in SplitH"); - - // Collapse and reopen: the split comes back stacked. - app.toggle_split(); - assert_eq!(app.layout, LayoutMode::Single); - app.toggle_split(); - assert_eq!(app.layout, LayoutMode::SplitH); } #[test] - fn collapsing_split_keeps_the_focused_session() { + fn closing_a_pane_lets_the_sibling_reclaim_the_space() { let mut app = make_app(); app.spawn_headless_session("one".into(), None).unwrap(); app.spawn_headless_session("two".into(), None).unwrap(); - app.toggle_split(); - app.focus_next_pane(); - - app.toggle_split(); + app.split_focused(SplitDir::Row); // panes [0, 1], focus on 1 - assert_eq!(app.layout, LayoutMode::Single); + // Close the focused (new) pane: pane 0 reclaims the space. + app.close_focused_pane(); + assert_eq!(app.panes, vec![Some(0)]); assert_eq!(app.focused_pane, 0); - assert_eq!(app.panes, [Some(1), None]); - assert_eq!(app.active_idx(), Some(1)); + assert_eq!(app.active_idx(), Some(0)); + + // Split again and close the first pane: the sibling (session 1) stays. + app.split_focused(SplitDir::Row); // panes [0, 1], focus 1 + app.focused_pane = 0; + app.close_focused_pane(); + assert_eq!(app.panes, vec![Some(1)]); } #[test] - fn docking_chat_opens_split_and_undocking_collapses_empty_pane() { + fn docking_chat_opens_a_pane_and_undocking_reclaims_it() { let mut app = make_app(); app.spawn_headless_session("one".into(), None).unwrap(); - // Dock from Single: opens a split with chat in the other pane, focused. + // Dock: splits the pane and puts chat in the new, focused pane. app.dock_chat(None); - assert_eq!(app.layout, LayoutMode::SplitV); + assert_eq!(app.panes.len(), 2); assert_eq!(app.chat_docked, Some(1)); assert_eq!(app.focused_pane, 1); + assert_eq!(app.panes[1], None, "the chat pane holds no session"); // Esc from the chat pane jumps back to the work pane. app.chat_key(crossterm::event::KeyEvent::new( @@ -6334,28 +6368,28 @@ mod tests { )); assert_eq!(app.focused_pane, 0); - // Undock: the empty pane collapses back to Single. + // Undock: the chat pane closes and the work pane reclaims the space. app.undock_chat(); assert_eq!(app.chat_docked, None); - assert_eq!(app.layout, LayoutMode::Single); - assert_eq!(app.panes, [Some(0), None]); + assert_eq!(app.panes, vec![Some(0)]); } #[test] - fn collapsing_split_undocks_chat_and_switching_replaces_docked_chat() { + fn closing_chat_pane_undocks_and_switching_replaces_docked_chat() { let mut app = make_app(); app.spawn_headless_session("one".into(), None).unwrap(); app.spawn_headless_session("two".into(), None).unwrap(); - app.dock_chat(Some(0)); - assert_eq!(app.chat_docked, Some(0)); - app.toggle_split(); - assert_eq!(app.chat_docked, None, "collapsing the split undocks chat"); + app.dock_chat(Some(SplitDir::Row)); + assert_eq!(app.chat_docked, Some(app.focused_pane)); + app.close_focused_pane(); + assert_eq!(app.chat_docked, None, "closing the chat pane undocks chat"); - app.dock_chat(Some(1)); + app.dock_chat(Some(SplitDir::Row)); + let chat_pane = app.focused_pane; app.switch_to(1); assert_eq!(app.chat_docked, None, "picking a session replaces chat"); - assert_eq!(app.panes[1], Some(1)); + assert_eq!(app.panes[chat_pane], Some(1)); } #[test] @@ -6364,19 +6398,18 @@ mod tests { app.spawn_headless_session("one".into(), None).unwrap(); app.spawn_headless_session("two".into(), None).unwrap(); app.spawn_headless_session("three".into(), None).unwrap(); - app.toggle_split(); - app.focus_next_pane(); + app.split_focused(SplitDir::Row); // panes [0, 1], focus 1 app.kill_active_session(); assert_eq!(app.sessions.len(), 2); - assert_eq!(app.panes, [Some(0), None]); + assert_eq!(app.panes, vec![Some(0), None]); assert_eq!(app.focused_pane, 1); app.panes[1] = Some(1); app.focus_next_pane(); app.kill_active_session(); - assert_eq!(app.panes, [None, Some(0)]); + assert_eq!(app.panes, vec![None, Some(0)]); } #[test] @@ -6391,19 +6424,19 @@ mod tests { app.handle_session_resizer(first, first_tx); app.handle_session_resizer(second, second_tx); app.handle_session_resizer(hidden, hidden_tx); - app.toggle_split(); + app.split_focused(SplitDir::Row); // panes [0, 1], focus on pane 1 - app.handle_pane_resize([(12, 40), (12, 39)]); + app.handle_pane_resize(&[(12, 40), (12, 39)]); - assert_eq!(app.pane_sizes, [(12, 40), (12, 39)]); + assert_eq!(app.pane_sizes, vec![(12, 40), (12, 39)]); assert_eq!(app.sessions[0].screen.screen().size(), (12, 40)); assert_eq!(app.sessions[1].screen.screen().size(), (12, 39)); assert_eq!(first_rx.try_recv().unwrap(), (12, 40)); assert_eq!(second_rx.try_recv().unwrap(), (12, 39)); - // Hidden sessions track the focused pane's size so their programs - // see the SIGWINCH immediately instead of on switch_to. - assert_eq!(app.sessions[2].screen.screen().size(), (12, 40)); - assert_eq!(hidden_rx.try_recv().unwrap(), (12, 40)); + // Hidden sessions track the focused pane's size (pane 1 here) so their + // programs see the SIGWINCH immediately instead of on switch_to. + assert_eq!(app.sessions[2].screen.screen().size(), (12, 39)); + assert_eq!(hidden_rx.try_recv().unwrap(), (12, 39)); } #[test] @@ -6426,13 +6459,12 @@ mod tests { app.spawn_headless_session("one".into(), None).unwrap(); app.spawn_headless_session("two".into(), None).unwrap(); let third = app.spawn_headless_session("three".into(), None).unwrap(); - app.toggle_split(); - app.handle_pane_resize([(12, 40), (12, 39)]); + app.split_focused(SplitDir::Row); // panes [0, 1], focus on pane 1 + app.handle_pane_resize(&[(12, 40), (12, 39)]); let (tx, mut rx) = mpsc::channel(1); app.handle_session_resizer(third, tx); - app.focus_next_pane(); - app.switch_to(2); + app.switch_to(2); // replace the focused pane (1) with session 2 assert_eq!(app.sessions[2].screen.screen().size(), (12, 39)); assert_eq!(rx.try_recv().unwrap(), (12, 39)); diff --git a/src/keybindings.rs b/src/keybindings.rs index 1be1f9e..a95b744 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -19,7 +19,9 @@ pub enum Action { OpenMenu, ToggleChat, DockChat, - ToggleSplit, + SplitPaneRight, + SplitPaneDown, + ClosePane, RotateSplit, FocusNextPane, BroadcastToggle, @@ -62,8 +64,10 @@ fn default_keymap() -> Keymap { m.insert((ctrl, KeyCode::Char(' ')), Action::OpenMenu); m.insert((alt, KeyCode::Char('t')), Action::ToggleChat); m.insert((alt, KeyCode::Char('g')), Action::DockChat); - m.insert((alt, KeyCode::Char('\\')), Action::ToggleSplit); - m.insert((alt, KeyCode::Char('-')), Action::RotateSplit); + m.insert((alt, KeyCode::Char('\\')), Action::SplitPaneRight); + m.insert((alt, KeyCode::Char('-')), Action::SplitPaneDown); + m.insert((alt, KeyCode::Char('w')), Action::ClosePane); + m.insert((alt, KeyCode::Char('r')), Action::RotateSplit); m.insert((alt, KeyCode::Char('o')), Action::FocusNextPane); m.insert((alt, KeyCode::Char('b')), Action::BroadcastToggle); m.insert((alt, KeyCode::Char('d')), Action::Detach); @@ -155,7 +159,9 @@ fn parse_action(s: &str) -> Option { "toggle_chat" | "chat" => Some(Action::ToggleChat), "dock_chat" | "chat_dock" => Some(Action::DockChat), "open_menu" => Some(Action::OpenMenu), - "toggle_split" => Some(Action::ToggleSplit), + "split_pane_right" | "split_right" => Some(Action::SplitPaneRight), + "split_pane_down" | "split_down" => Some(Action::SplitPaneDown), + "close_pane" => Some(Action::ClosePane), "rotate_split" => Some(Action::RotateSplit), "focus_next_pane" => Some(Action::FocusNextPane), "broadcast_toggle" => Some(Action::BroadcastToggle), @@ -186,7 +192,7 @@ mod tests { ); assert_eq!( map.get(&(KeyModifiers::ALT, KeyCode::Char('\\'))), - Some(&Action::ToggleSplit) + Some(&Action::SplitPaneRight) ); assert_eq!( map.get(&(KeyModifiers::ALT, KeyCode::Char('o'))), @@ -249,7 +255,11 @@ mod tests { fn parse_action_accepts_only_valid_switch_ranges() { assert_eq!(parse_action("switch_1"), Some(Action::SwitchSession(0))); assert_eq!(parse_action("switch_8"), Some(Action::SwitchSession(7))); - assert_eq!(parse_action("toggle_split"), Some(Action::ToggleSplit)); + assert_eq!( + parse_action("split_pane_right"), + Some(Action::SplitPaneRight) + ); + assert_eq!(parse_action("close_pane"), Some(Action::ClosePane)); assert_eq!(parse_action("rotate_split"), Some(Action::RotateSplit)); assert_eq!(parse_action("focus_next_pane"), Some(Action::FocusNextPane)); assert_eq!(parse_action("switch_0"), None); diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..7f741cd --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,247 @@ +//! Recursive pane layout tree. +//! +//! The output zone is a binary tree of splits. Each `Leaf` occupies one pane +//! slot; the app keeps parallel vectors (`panes`, `pane_sizes`) indexed by the +//! tree's in-order leaf position, so leaf N in `rects()` corresponds to +//! `panes[N]`. This lets a pane be split any number of times, in either +//! direction, instead of the old fixed two-pane layout. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// How a split divides its area between its two children. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitDir { + /// Children side by side, left | right (a vertical divider). + Row, + /// Children stacked, top / bottom (a horizontal divider). + Col, +} + +impl SplitDir { + pub fn flip(self) -> SplitDir { + match self { + SplitDir::Row => SplitDir::Col, + SplitDir::Col => SplitDir::Row, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum LayoutTree { + #[default] + Leaf, + Split { + dir: SplitDir, + first: Box, + second: Box, + }, +} + +impl LayoutTree { + /// Number of pane slots (leaves) in the tree. + #[cfg_attr(not(test), allow(dead_code))] + pub fn leaf_count(&self) -> usize { + match self { + LayoutTree::Leaf => 1, + LayoutTree::Split { first, second, .. } => first.leaf_count() + second.leaf_count(), + } + } + + /// Rectangles for each leaf, in in-order traversal order (matching the + /// app's `panes` vector). + pub fn rects(&self, area: Rect) -> Vec { + let mut out = Vec::new(); + self.collect_rects(area, &mut out); + out + } + + fn collect_rects(&self, area: Rect, out: &mut Vec) { + match self { + LayoutTree::Leaf => out.push(area), + LayoutTree::Split { dir, first, second } => { + let direction = match dir { + SplitDir::Row => Direction::Horizontal, + SplitDir::Col => Direction::Vertical, + }; + let chunks = Layout::default() + .direction(direction) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + first.collect_rects(chunks[0], out); + second.collect_rects(chunks[1], out); + } + } + } + + /// Split the leaf at in-order position `index` into two, keeping the + /// existing content in the first child. A new empty leaf appears at + /// `index + 1`. No-op if `index` is out of range. + pub fn split_leaf(&mut self, index: usize, dir: SplitDir) { + let mut counter = 0; + self.split_at(index, dir, &mut counter); + } + + fn split_at(&mut self, target: usize, dir: SplitDir, counter: &mut usize) -> bool { + match self { + LayoutTree::Leaf => { + if *counter == target { + *self = LayoutTree::Split { + dir, + first: Box::new(LayoutTree::Leaf), + second: Box::new(LayoutTree::Leaf), + }; + return true; + } + *counter += 1; + false + } + LayoutTree::Split { first, second, .. } => { + first.split_at(target, dir, counter) || second.split_at(target, dir, counter) + } + } + } + + /// Remove the leaf at in-order position `index`; its sibling subtree + /// expands to reclaim the space. Returns false (no change) if `index` is + /// the sole remaining leaf or out of range. + pub fn close_leaf(&mut self, index: usize) -> bool { + if matches!(self, LayoutTree::Leaf) { + return false; + } + let mut counter = 0; + self.close_at(index, &mut counter) + } + + fn close_at(&mut self, target: usize, counter: &mut usize) -> bool { + let LayoutTree::Split { first, second, .. } = self else { + *counter += 1; + return false; + }; + // First child. + if matches!(**first, LayoutTree::Leaf) { + if *counter == target { + let sibling = std::mem::take(second.as_mut()); + *self = sibling; + return true; + } + *counter += 1; + } else if first.close_at(target, counter) { + return true; + } + // Second child (re-borrow after the possible mutation above). + let LayoutTree::Split { first, second, .. } = self else { + return false; + }; + if matches!(**second, LayoutTree::Leaf) { + if *counter == target { + let sibling = std::mem::take(first.as_mut()); + *self = sibling; + return true; + } + *counter += 1; + } else if second.close_at(target, counter) { + return true; + } + false + } + + /// Flip the direction of the split that directly parents the leaf at + /// in-order position `index`. + pub fn rotate_leaf(&mut self, index: usize) -> bool { + let mut counter = 0; + self.rotate_at(index, &mut counter) + } + + fn rotate_at(&mut self, target: usize, counter: &mut usize) -> bool { + let LayoutTree::Split { dir, first, second } = self else { + *counter += 1; + return false; + }; + if matches!(**first, LayoutTree::Leaf) { + if *counter == target { + *dir = dir.flip(); + return true; + } + *counter += 1; + } else if first.rotate_at(target, counter) { + return true; + } + if matches!(**second, LayoutTree::Leaf) { + if *counter == target { + *dir = dir.flip(); + return true; + } + *counter += 1; + } else if second.rotate_at(target, counter) { + return true; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_leaf_covers_the_whole_area() { + let tree = LayoutTree::Leaf; + let area = Rect::new(0, 0, 100, 40); + assert_eq!(tree.rects(area), vec![area]); + assert_eq!(tree.leaf_count(), 1); + } + + #[test] + fn split_grows_leaf_count_and_targets_the_right_leaf() { + let mut tree = LayoutTree::Leaf; + tree.split_leaf(0, SplitDir::Row); // 2 leaves + assert_eq!(tree.leaf_count(), 2); + tree.split_leaf(0, SplitDir::Col); // split the first → 3 leaves + assert_eq!(tree.leaf_count(), 3); + } + + #[test] + fn three_pane_rects_tile_without_overlap_or_gap() { + // Leaf0 split Row → [0,1]; split leaf0 Col → top/bottom + right. + let mut tree = LayoutTree::Leaf; + tree.split_leaf(0, SplitDir::Row); + tree.split_leaf(0, SplitDir::Col); + let area = Rect::new(0, 0, 100, 40); + let rects = tree.rects(area); + assert_eq!(rects.len(), 3); + let covered: u16 = rects.iter().map(|r| r.area()).sum(); + assert_eq!(covered, area.area(), "panes tile the area exactly"); + // No two rects overlap. + for (i, a) in rects.iter().enumerate() { + for b in &rects[i + 1..] { + assert!(a.intersection(*b).area() == 0, "panes must not overlap"); + } + } + } + + #[test] + fn close_collapses_to_sibling() { + let mut tree = LayoutTree::Leaf; + tree.split_leaf(0, SplitDir::Row); + tree.split_leaf(0, SplitDir::Col); + assert_eq!(tree.leaf_count(), 3); + assert!(tree.close_leaf(1)); + assert_eq!(tree.leaf_count(), 2); + // Closing down to a single leaf is allowed… + assert!(tree.close_leaf(0)); + assert_eq!(tree.leaf_count(), 1); + // …but the final leaf cannot be closed. + assert!(!tree.close_leaf(0)); + } + + #[test] + fn rotate_flips_the_parent_split_only() { + let mut tree = LayoutTree::Leaf; + tree.split_leaf(0, SplitDir::Row); + assert!(tree.rotate_leaf(0)); + match &tree { + LayoutTree::Split { dir, .. } => assert_eq!(*dir, SplitDir::Col), + _ => panic!("expected a split"), + } + } +} diff --git a/src/main.rs b/src/main.rs index 8e7e147..baeeb6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod doctor; mod events; mod ipc; mod keybindings; +mod layout; mod notify; mod opencode_log; mod orchestrator; @@ -48,57 +49,117 @@ async fn main() -> anyhow::Result<()> { std::process::exit(doctor::run()); } let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "-r" || a == "--reattach") { - return reattach::run_relay_client().await; - } if args.iter().any(|a| a == "--server") { return run_server().await; } - // ── Default path: client/server split (tmux-style) ───────────────────── - // `linkshell` is a thin launcher: make sure a detached server exists, - // then run the relay client in the foreground. Detaching just exits the + // `linkshell ls` / `list` — enumerate detached sessions and exit. + if matches!(args.get(1).map(String::as_str), Some("ls") | Some("list")) { + list_sessions_cli(); + return Ok(()); + } + + // `linkshell -r [id]` / `--reattach [id]` / `attach [id]` — reattach to an + // existing session. With no id, attach the single running session (error + // if there are several). Never spawns a new server. + if let Some(pos) = args + .iter() + .position(|a| a == "-r" || a == "--reattach" || a == "attach") + { + let requested = args.get(pos + 1).filter(|a| !a.starts_with('-')).cloned(); + return attach_existing(requested).await; + } + + // ── Default path: always spawn a fresh detached server (screen-style) ── + // `linkshell` (optionally `linkshell new [name]`) starts a new server and + // attaches the relay client in the foreground. Detaching just exits the // client — the shell comes back immediately and the server keeps running. - launch_and_attach().await + let name = if args.get(1).map(String::as_str) == Some("new") { + args.get(2).filter(|a| !a.starts_with('-')).cloned() + } else { + None + }; + launch_and_attach(name).await } -/// Ensure a linkshell server is running (spawning one detached if needed), -/// then attach the relay client to it in the foreground. -async fn launch_and_attach() -> anyhow::Result<()> { - let already_running = reattach::server_alive(); - if !already_running { - spawn_server_detached()?; - // Wait for the server to write its reattach info file and bind the - // socket. The server does this early in startup, so 5s is generous. - // The socket path must come from the info file, not be recomputed - // here: the default path embeds the server's pid, and the info file - // is written before the socket is bound — waiting for both avoids - // racing the bind. - let mut ready = false; - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(50)).await; - if let Some(sock) = reattach::recorded_reattach_socket() { - if std::path::Path::new(&sock).exists() { - ready = true; - break; - } +/// Print the live session registry, screen-style. +fn list_sessions_cli() { + let sessions = reattach::list_sessions(); + if sessions.is_empty() { + println!("No detached linkshell sessions."); + return; + } + println!("{:<8} {:<20} {:>8} STATUS", "ID", "NAME", "PID"); + for s in &sessions { + let name = if s.name.is_empty() { "-" } else { &s.name }; + let status = if s.attached { "attached" } else { "detached" }; + println!("{:<8} {:<20} {:>8} {}", s.id, name, s.pid, status); + } + println!("\nReattach with: linkshell -r "); +} + +/// Reattach to an existing detached session. `requested` selects one by id; +/// when absent, the single running session is chosen (ambiguous if several). +async fn attach_existing(requested: Option) -> anyhow::Result<()> { + let sessions = reattach::list_sessions(); + let id = match requested { + Some(id) => id, + None => match sessions.as_slice() { + [] => anyhow::bail!("no detached linkshell sessions (start one with `linkshell`)"), + [only] => only.id.clone(), + _ => { + let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect(); + anyhow::bail!( + "multiple sessions running; pick one with `linkshell -r `\n sessions: {}", + ids.join(", ") + ); + } + }, + }; + let result = reattach::run_relay_client(&id).await; + if result.is_ok() { + println!( + "[linkshell] detached — sessions keep running; run `linkshell -r {id}` to reattach" + ); + } + result +} + +/// Spawn a fresh detached server with a new session id, then attach the relay +/// client to it in the foreground. +async fn launch_and_attach(name: Option) -> anyhow::Result<()> { + let id = reattach::new_session_id(); + spawn_server_detached(&id, name.as_deref())?; + + // Wait for the server to register its session entry and bind the reattach + // socket. The server does this early in startup, so 5s is generous. The + // socket path must come from the entry, not be recomputed here: the + // default path embeds the server's pid, and the entry is written before + // the socket is bound — waiting for both avoids racing the bind. + let mut ready = false; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(50)).await; + if let Some(entry) = reattach::read_session_entry(&id) { + if std::path::Path::new(&entry.reattach).exists() { + ready = true; + break; } } - if !ready { - anyhow::bail!( - "linkshell server did not start within 5s\n \ - (run `linkshell --server` in the foreground to see errors, \ - or check {})", - reattach::server_log_path_display() - ); - } - } else { - eprintln!("[linkshell] attaching to existing session"); + } + if !ready { + anyhow::bail!( + "linkshell server did not start within 5s\n \ + (run `linkshell --server` in the foreground to see errors, \ + or check {})", + reattach::server_log_path_display() + ); } - let result = reattach::run_relay_client().await; + let result = reattach::run_relay_client(&id).await; if result.is_ok() { - println!("[linkshell] detached — sessions keep running; run `linkshell` to reattach"); + println!( + "[linkshell] detached — sessions keep running; run `linkshell -r {id}` to reattach" + ); } result } @@ -106,8 +167,9 @@ async fn launch_and_attach() -> anyhow::Result<()> { /// Spawn `linkshell --server` as a daemon: new session (setsid) so it has no /// controlling terminal and survives SSH drops, stdio detached from our tty. /// stderr goes to a log file so server-side startup errors are diagnosable. -/// All CLI flags (--profile, --council, --tcp, ...) are forwarded verbatim. -fn spawn_server_detached() -> anyhow::Result<()> { +/// CLI flags (--profile, --council, --tcp, ...) are forwarded; the session id +/// and name travel via env. +fn spawn_server_detached(id: &str, name: Option<&str>) -> anyhow::Result<()> { use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; @@ -116,11 +178,19 @@ fn spawn_server_detached() -> anyhow::Result<()> { .map(Stdio::from) .unwrap_or_else(Stdio::null); let mut cmd = Command::new(exe); + // Forward only flags (--profile, --council, --tcp, …); positional + // launcher tokens like `new`/`` are consumed here, not by the + // server. The session id and name travel via env instead. + let flag_args = std::env::args().skip(1).filter(|a| a.starts_with('-')); cmd.arg("--server") - .args(std::env::args().skip(1)) + .args(flag_args) + .env("LINKSHELL_SESSION_ID", id) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(stderr); + if let Some(name) = name { + cmd.env("LINKSHELL_SESSION_NAME", name); + } unsafe { cmd.pre_exec(|| { // Detach from the launcher's session and controlling terminal, @@ -233,18 +303,33 @@ async fn run_server() -> anyhow::Result<()> { // No local stdin input reader: all input arrives as JSON relay events // from the attached client via the reattach socket. - // Write the reattach info file so `linkshell -r` can find this session. - // The minted token authenticates relay clients; it lives only in the - // 0600 info file, so only the owning user can reattach. + // Register this session so `linkshell ls` / `linkshell -r ` can find + // it. The minted token authenticates relay clients; it lives only in the + // 0600 entry file, so only the owning user can reattach. + let session_id = + std::env::var("LINKSHELL_SESSION_ID").unwrap_or_else(|_| reattach::new_session_id()); + let session_name = std::env::var("LINKSHELL_SESSION_NAME").unwrap_or_default(); let ipc_socket_path = ipc::socket_path(&config); let reattach_token = auth::mint_token(); - reattach::write_reattach_info(std::process::id(), &ipc_socket_path, &reattach_token); + let reattach_socket_path = reattach::reattach_socket_from_ipc(&ipc_socket_path); + reattach::write_session_entry(&reattach::SessionEntry { + id: session_id.clone(), + name: session_name, + pid: std::process::id(), + socket: ipc_socket_path.clone(), + reattach: reattach_socket_path.clone(), + token: reattach_token.clone(), + created: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + attached: false, + }); // Reattach socket — accepts a single relay client (plain `linkshell` or // `linkshell -r`). One client at a time by design: a second connection // attempt is rejected at the handshake while `attached` is set. let attached = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let reattach_socket_path = reattach::reattach_socket_from_ipc(&ipc_socket_path); spawn_reattach_listener( tx.clone(), reattach_socket_path.clone(), @@ -321,15 +406,18 @@ async fn run_server() -> anyhow::Result<()> { app.needs_redraw = false; last_render = Instant::now(); - let mut sizes = app.pane_sizes; - for (idx, area) in layout.output_areas.iter().take(2).enumerate() { + let mut sizes = app.pane_sizes.clone(); + for (idx, area) in layout.output_areas.iter().enumerate() { + if idx >= sizes.len() { + break; + } sizes[idx] = ( area.height.saturating_sub(2).max(1), area.width.saturating_sub(2).max(1), ); } - let old_sizes = app.pane_sizes; - app.handle_pane_resize(sizes); + let old_sizes = app.pane_sizes.clone(); + app.handle_pane_resize(&sizes); if app.pane_sizes != old_sizes { app.needs_redraw = true; } @@ -368,6 +456,7 @@ async fn run_server() -> anyhow::Result<()> { } headless = true; attached.store(false, std::sync::atomic::Ordering::SeqCst); + reattach::set_session_attached(&session_id, false); } } Some(AppEvent::Resize { cols, rows }) => { @@ -392,6 +481,7 @@ async fn run_server() -> anyhow::Result<()> { ).await; relay_kitty = kitty; attached.store(true, std::sync::atomic::Ordering::SeqCst); + reattach::set_session_attached(&session_id, true); *term_size.lock().unwrap() = (cols, rows); headless = false; app.needs_redraw = true; @@ -399,7 +489,8 @@ async fn run_server() -> anyhow::Result<()> { let chrome_rows = 3 + (app.sessions.len().max(1) as u16 + 4); let pty_rows = rows.saturating_sub(chrome_rows).max(1); let pty_cols = cols.saturating_sub(2).max(1); - app.handle_pane_resize([(pty_rows, pty_cols); 2]); + let sizes = vec![(pty_rows, pty_cols); app.pane_sizes.len()]; + app.handle_pane_resize(&sizes); } Some(other) => handle_event(&mut app, other), } @@ -421,7 +512,7 @@ async fn run_server() -> anyhow::Result<()> { if let Some(task) = relay_task.take() { task.abort(); } - reattach::clear_reattach_info(); + reattach::remove_session_entry(&session_id); let _ = std::fs::remove_file(&reattach_socket_path); ipc::cleanup(&config); @@ -719,7 +810,9 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.dock_chat(None); } } - Action::ToggleSplit => app.toggle_split(), + Action::SplitPaneRight => app.split_focused(layout::SplitDir::Row), + Action::SplitPaneDown => app.split_focused(layout::SplitDir::Col), + Action::ClosePane => app.close_focused_pane(), Action::RotateSplit => app.rotate_split(), Action::FocusNextPane => app.focus_next_pane(), Action::BroadcastToggle => { diff --git a/src/reattach.rs b/src/reattach.rs index f17c523..cd48e56 100644 --- a/src/reattach.rs +++ b/src/reattach.rs @@ -104,7 +104,35 @@ impl ratatui::backend::Backend for SizedBackend { } } -// ── Session / reattach file ──────────────────────────────────────────────── +// ── Session registry ─────────────────────────────────────────────────────── +// Each detached server records itself as a JSON entry under +// `/sessions/.json`, screen-style. Multiple servers coexist, each +// with its own id, pid, and per-instance sockets. `linkshell ls` enumerates +// the registry (pruning entries whose pid has died); `linkshell -r ` +// attaches to a specific one. + +use serde::{Deserialize, Serialize}; + +/// A registered detached linkshell server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionEntry { + pub id: String, + #[serde(default)] + pub name: String, + pub pid: u32, + /// IPC socket path. + pub socket: String, + /// Reattach (relay) socket path. + pub reattach: String, + pub token: String, + /// Unix timestamp (seconds) when the server started. + #[serde(default)] + pub created: u64, + /// Best-effort: whether a relay client is currently attached. Updated by + /// the server on attach/detach; may be stale if the server crashed. + #[serde(default)] + pub attached: bool, +} fn linkshell_config_dir() -> Option { if let Some(p) = std::env::var_os("XDG_CONFIG_HOME") { @@ -115,8 +143,36 @@ fn linkshell_config_dir() -> Option { .map(|h| h.join(".config").join("linkshell")) } -fn reattach_info_path() -> Option { - linkshell_config_dir().map(|d| d.join("reattach")) +fn sessions_dir() -> Option { + linkshell_config_dir().map(|d| d.join("sessions")) +} + +fn session_entry_path(id: &str) -> Option { + sessions_dir().map(|d| d.join(format!("{id}.json"))) +} + +/// True if `pid` is a live process owned by (signalable by) this user. +pub fn pid_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +/// A short, filesystem-safe session id. Derived from the wall clock and pid so +/// concurrent launches don't collide. +pub fn new_session_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let mix = nanos ^ ((std::process::id() as u128) << 17); + // 6 base36 characters — plenty of entropy for a per-user session table. + let mut n = mix; + let mut s = String::new(); + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + for _ in 0..6 { + s.push(ALPHABET[(n % 36) as usize] as char); + n /= 36; + } + s } pub fn reattach_socket_from_ipc(ipc_socket: &str) -> String { @@ -128,61 +184,78 @@ pub fn reattach_socket_from_ipc(ipc_socket: &str) -> String { } } -pub fn write_reattach_info(pid: u32, ipc_socket: &str, token: &str) { - let Some(path) = reattach_info_path() else { +/// Write (or overwrite) a session's registry entry. The entry carries the +/// reattach token, so keep it readable by the owner only. +pub fn write_session_entry(entry: &SessionEntry) { + let Some(dir) = sessions_dir() else { return; }; - let info = serde_json::json!({ - "pid": pid, - "socket": ipc_socket, - "reattach": reattach_socket_from_ipc(ipc_socket), - "token": token, - }); - // The file carries the reattach token; keep it readable by the owner only. - if std::fs::write(&path, info.to_string()).is_ok() { - use std::os::unix::fs::PermissionsExt; + let _ = std::fs::create_dir_all(&dir); + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + let Some(path) = session_entry_path(&entry.id) else { + return; + }; + let Ok(json) = serde_json::to_string(entry) else { + return; + }; + if std::fs::write(&path, json).is_ok() { let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); } } -/// The reattach socket path recorded in the info file, if present. This is -/// the server's own view of the path — the default socket path embeds the -/// server's pid, so other processes must read it from here rather than -/// recompute it. -pub fn recorded_reattach_socket() -> Option { - let path = reattach_info_path()?; +/// Read one session entry by id (no liveness check). +pub fn read_session_entry(id: &str) -> Option { + let path = session_entry_path(id)?; let bytes = std::fs::read(path).ok()?; - let info: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - info["reattach"].as_str().map(str::to_string) + serde_json::from_slice(&bytes).ok() } -pub fn clear_reattach_info() { - if let Some(path) = reattach_info_path() { +/// Update the `attached` flag on a session entry in place. Best-effort. +pub fn set_session_attached(id: &str, attached: bool) { + if let Some(mut entry) = read_session_entry(id) { + entry.attached = attached; + write_session_entry(&entry); + } +} + +pub fn remove_session_entry(id: &str) { + if let Some(path) = session_entry_path(id) { let _ = std::fs::remove_file(path); } } -/// True if a linkshell server appears to be running: the reattach info file -/// exists and its recorded pid is alive. A stale file (dead pid) is removed -/// so the launcher can start a fresh server. -pub fn server_alive() -> bool { - let Some(path) = reattach_info_path() else { - return false; - }; - let Ok(bytes) = std::fs::read(&path) else { - return false; +/// All live sessions, sorted by creation time. Entries whose pid has died are +/// pruned from disk as a side effect. +pub fn list_sessions() -> Vec { + let Some(dir) = sessions_dir() else { + return Vec::new(); }; - let Ok(info) = serde_json::from_slice::(&bytes) else { - return false; + let Ok(read_dir) = std::fs::read_dir(&dir) else { + return Vec::new(); }; - let Some(pid) = info["pid"].as_u64() else { - return false; - }; - let alive = unsafe { libc::kill(pid as i32, 0) == 0 }; - if !alive { - let _ = std::fs::remove_file(&path); + let mut out = Vec::new(); + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(session) = serde_json::from_slice::(&bytes) else { + // Unparseable entry — drop it so it stops cluttering the list. + let _ = std::fs::remove_file(&path); + continue; + }; + if pid_alive(session.pid) { + out.push(session); + } else { + let _ = std::fs::remove_file(&path); + } } - alive + out.sort_by_key(|s| s.created); + out } fn server_log_path() -> Option { @@ -206,7 +279,7 @@ pub fn open_server_log() -> Option { // ── Relay client: `linkshell -r` / `linkshell --reattach` ───────────────── -pub async fn run_relay_client() -> anyhow::Result<()> { +pub async fn run_relay_client(id: &str) -> anyhow::Result<()> { use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture}, execute, @@ -214,37 +287,16 @@ pub async fn run_relay_client() -> anyhow::Result<()> { }; // ── Find the detached session ───────────────────────────────────────── - let info_path = - reattach_info_path().ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - - let info_bytes = std::fs::read(&info_path).map_err(|_| { - anyhow::anyhow!( - "no detached linkshell session found\n (expected {})", - info_path.display() - ) + let entry = read_session_entry(id).ok_or_else(|| { + anyhow::anyhow!("no linkshell session '{}' found (try `linkshell ls`)", id) })?; - - let info: serde_json::Value = serde_json::from_slice(&info_bytes)?; - let pid = info["pid"] - .as_u64() - .ok_or_else(|| anyhow::anyhow!("invalid reattach file: missing pid"))? as i32; - let reattach_socket = info["reattach"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("invalid reattach file: missing socket"))? - .to_string(); - let token = info["token"] - .as_str() - .ok_or_else(|| { - anyhow::anyhow!( - "invalid reattach file: missing token (session started by an older linkshell?)" - ) - })? - .to_string(); + let pid = entry.pid as i32; + let reattach_socket = entry.reattach.clone(); + let token = entry.token.clone(); // ── Check the process is alive ──────────────────────────────────────── - let alive = unsafe { libc::kill(pid, 0) == 0 }; - if !alive { - let _ = std::fs::remove_file(&info_path); + if !pid_alive(entry.pid) { + remove_session_entry(id); anyhow::bail!("no active linkshell session (pid {} is not running)", pid); } @@ -439,6 +491,24 @@ mod tests { use super::*; use ratatui::backend::Backend; + #[test] + fn session_ids_are_six_base36_chars() { + let id = new_session_id(); + assert_eq!(id.len(), 6); + assert!(id + .chars() + .all(|c| c.is_ascii_alphanumeric() && !c.is_ascii_uppercase())); + } + + #[test] + fn reattach_socket_is_derived_from_ipc_socket() { + assert_eq!( + reattach_socket_from_ipc("/run/user/1000/linkshell/42.sock"), + "/run/user/1000/linkshell/42.reattach" + ); + assert_eq!(reattach_socket_from_ipc("/tmp/foo"), "/tmp/foo.reattach"); + } + #[test] fn relay_resize_lines_carry_dimensions() { let ev = decode_relay_line(r#"{"t":"r","cols":120,"rows":40}"#).unwrap(); diff --git a/src/ui.rs b/src/ui.rs index 23a23dd..1e2ac21 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,8 @@ use ratatui::{ Frame, }; -use crate::app::{App, AppMode, FileBrowserState, LayoutMode, NewSessionField, Selection, MENU}; +use crate::app::{App, AppMode, FileBrowserState, NewSessionField, Selection, MENU}; +use crate::layout::LayoutTree; use crate::session::{SessionKind, SessionState}; use vt100::Screen; @@ -185,7 +186,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { let mut chat_area = Rect::default(); let mut chat_layout = ChatLayout::default(); - let output_areas = split_output_areas(chunks[0], app.layout); + let output_areas = split_output_areas(chunks[0], &app.tree); for (pane_idx, area) in output_areas.iter().copied().enumerate() { if app.chat_docked == Some(pane_idx) && output_areas.len() > 1 { chat_layout = draw_chat_in(f, app, area, pane_idx == app.focused_pane); @@ -268,19 +269,8 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { } } -fn split_output_areas(area: Rect, mode: LayoutMode) -> Vec { - // Note the naming inversion: our SplitV ("vertical split", panes side by - // side) is ratatui's Direction::Horizontal, and vice versa. - let direction = match mode { - LayoutMode::Single => return vec![area], - LayoutMode::SplitV => Direction::Horizontal, - LayoutMode::SplitH => Direction::Vertical, - }; - Layout::default() - .direction(direction) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area) - .to_vec() +fn split_output_areas(area: Rect, tree: &LayoutTree) -> Vec { + tree.rects(area) } // ── Main output zone ─────────────────────────────────────────────────────── @@ -1176,10 +1166,8 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { ("alt-c", "Open command bar"), ("alt-t", "Toggle agent chat pane"), ("alt-g", "Dock/undock chat as a split pane"), - ( - "alt-\\ / alt-- / alt-o", - "Toggle / rotate / switch split panes", - ), + ("alt-\\ / alt--", "Split focused pane right / down"), + ("alt-w / alt-r / alt-o", "Close / rotate / focus next pane"), ("alt-shift-pgup/pgdn", "Scroll output (any session)"), ("alt-h", "Show this help"), ("ctrl-space", "Toggle menu bar"), @@ -2273,8 +2261,13 @@ mod tests { fn split_layout_returns_two_non_overlapping_output_areas() { let area = Rect::new(3, 4, 101, 20); - let single = split_output_areas(area, LayoutMode::Single); - let split = split_output_areas(area, LayoutMode::SplitV); + use crate::layout::SplitDir; + + let single = split_output_areas(area, &LayoutTree::Leaf); + + let mut row = LayoutTree::Leaf; + row.split_leaf(0, SplitDir::Row); + let split = split_output_areas(area, &row); assert_eq!(single, vec![area]); assert_eq!(split.len(), 2); @@ -2284,7 +2277,9 @@ mod tests { assert_eq!(split[0].height, area.height); assert_eq!(split[1].height, area.height); - let stacked = split_output_areas(area, LayoutMode::SplitH); + let mut col = LayoutTree::Leaf; + col.split_leaf(0, SplitDir::Col); + let stacked = split_output_areas(area, &col); assert_eq!(stacked.len(), 2); assert_eq!(stacked[0].y, area.y); assert_eq!(stacked[1].y, stacked[0].y + stacked[0].height);