diff --git a/app/src/App.tsx b/app/src/App.tsx index 70c2648..16d6023 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,7 +3,6 @@ import { LayoutEngine } from "./LayoutEngine"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; -import { SearchBar } from "./SearchBar"; import { Wizard } from "./Wizard"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; @@ -35,9 +34,10 @@ export function App() { const [health, setHealth] = useState(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); - const [searchOpen, setSearchOpen] = useState(false); + const [searchSurfaceId, setSearchSurfaceId] = useState(null); const [searchNonce, setSearchNonce] = useState(0); const activeRef = useRef(null); + const effectiveFocusRef = useRef(null); const wsRef = useRef([]); activeRef.current = activeId; wsRef.current = workspaces; @@ -107,7 +107,11 @@ export function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { - if (activeRef.current) { e.preventDefault(); setSearchOpen(true); setSearchNonce((n) => n + 1); } + if (activeRef.current && effectiveFocusRef.current) { + e.preventDefault(); + setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel + setSearchNonce((n) => n + 1); + } } }; window.addEventListener("keydown", onKey); @@ -121,6 +125,7 @@ export function App() { const active = workspaces.find((w) => w.id === activeId) ?? null; const leaves = active ? leafIds(active.layout) : []; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; + effectiveFocusRef.current = effectiveFocus; function selectWorkspace(id: string) { setActiveId(id); @@ -135,13 +140,12 @@ export function App() { {sidebarOpen && setWizard(true)} health={health} connected={connected} />}
{active && ( - { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> + { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> )}
{active - ? + ? setSearchSurfaceId(null)} /> :
No workspace — create one to begin.
} - {searchOpen && active && setSearchOpen(false)} />}
{eventsOpen && ( diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index 623ffba..21be0e3 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react"; import { TerminalView } from "./TerminalView"; +import { SearchBar } from "./SearchBar"; import { StatusRing } from "./StatusRing"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; @@ -16,6 +17,11 @@ interface Props { focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; + /** The surface whose scrollback search bar is open, or null. Anchored to the + * panel it was opened on — it does NOT follow focus. */ + searchSurfaceId: string | null; + searchNonce: number; + onCloseSearch: () => void; } type Edge = "left" | "right" | "top" | "bottom"; @@ -34,7 +40,7 @@ function shortPath(cwd: string): string { return leaf ? `~/${leaf}` : cwd; } -export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) { +export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch }: Props) { // Panel drag-to-reorder. Implemented with raw pointer events rather than the // HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses. const [drop, setDrop] = useState(null); @@ -72,7 +78,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f if (!layout) { return
Empty workspace — apply a preset to add panels.
; } - const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag }; + const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }; if (zoomed) { return (
@@ -94,6 +100,9 @@ interface NodeProps { zoomed: string | null; drop: DropTarget | null; onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; + searchSurfaceId: string | null; + searchNonce: number; + onCloseSearch: () => void; } function Node({ node, path, ...rest }: NodeProps) { @@ -103,7 +112,7 @@ function Node({ node, path, ...rest }: NodeProps) { return ; } -function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit & { id: string }) { +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit & { id: string }) { const focused = focusedId === id; const dropEdge = drop && drop.id === id ? drop.edge : null; @@ -114,7 +123,11 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", - border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`, + // Constant 2px border, color-only on focus. A width change (1px<->2px) + // would resize the inner content box, fire ResizeObserver -> fit -> PTY + // SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus + // switch (the "stacked prompts" bug). + border: `2px solid ${focused ? COLORS.accent : COLORS.borderSubtle}`, boxSizing: "border-box", }} > @@ -170,6 +183,9 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
+ {searchSurfaceId === id && ( + + )} ); } diff --git a/app/src/SearchBar.tsx b/app/src/SearchBar.tsx index 1956a51..18d4036 100644 --- a/app/src/SearchBar.tsx +++ b/app/src/SearchBar.tsx @@ -61,18 +61,20 @@ export function SearchBar({ return (
@@ -100,7 +102,7 @@ export function SearchBar({ }} placeholder="Search scrollback" style={{ - width: 200, + width: 150, background: "transparent", border: "none", outline: "none", diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index 661ca68..f16ebb6 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -14,7 +14,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { useEffect(() => { if (!ref.current) return; - const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 }); + // allowProposedApi is required by the search addon: its match decorations + // call registerMarker/registerDecoration (proposed API). Without it findNext + // throws and the scrollback search counter never updates. + const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true }); try { term.loadAddon(new WebglAddon()); } catch { diff --git a/crates/spaceshd/src/surface.rs b/crates/spaceshd/src/surface.rs index a81e345..e8aea9c 100644 --- a/crates/spaceshd/src/surface.rs +++ b/crates/spaceshd/src/surface.rs @@ -10,6 +10,12 @@ use tokio::time::{Duration, Instant}; /// Spawn (or restart) a surface actor from a persisted spec. Injects /// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`. +/// +/// The child process is spawned lazily — see `spawn_surface_deferred`. This +/// lets the first `Resize` from an attaching GUI fix the PTY geometry *before* +/// the shell prints its first prompt, so prompts (e.g. powerlevel10k instant +/// prompt) render at the correct size instead of being drawn at the 80x24 +/// default and then reflowed. pub fn spawn_from_spec( id: SurfaceId, workspace_id: WorkspaceId, @@ -21,21 +27,25 @@ pub fn spawn_from_spec( ) -> std::io::Result { let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())]; env.extend(extra_env); - let pty = PtyHandle::spawn(SpawnSpec { + let spawn_spec = SpawnSpec { command: spec.command.clone(), args: spec.args.clone(), cwd: std::path::PathBuf::from(&spec.cwd), cols: spec.cols, rows: spec.rows, env, - }) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; - Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, hooks_active, state_tx, exit_tx)) + }; + Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx)) } const BROADCAST_CAP: usize = 1024; const FLUSH_INTERVAL: Duration = Duration::from_millis(6); const FLUSH_BYTES: usize = 16 * 1024; +/// How long a deferred surface waits for the first `Resize` before spawning the +/// child at its default geometry. The GUI's fit-driven resize lands within a +/// frame (<16ms); this only bounds the wait for headless/CLI surfaces that +/// never attach a GUI. +const SPAWN_FALLBACK: Duration = Duration::from_millis(250); pub enum SurfaceMsg { Input(Vec), @@ -53,22 +63,108 @@ pub struct SurfaceHandle { pub tx: mpsc::Sender, } +/// Eager variant: spawn the actor over an already-spawned PTY. Retained for +/// tests and callers that have a live PTY in hand; production goes through +/// `spawn_surface_deferred` via `spawn_from_spec`. +#[allow(dead_code)] pub fn spawn_surface( id: SurfaceId, workspace_id: WorkspaceId, - mut pty: PtyHandle, + pty: PtyHandle, cols: u16, rows: u16, hooks_active: bool, state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, +) -> SurfaceHandle { + let (tx, rx) = mpsc::channel::(64); + let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); + tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, Vec::new())); + SurfaceHandle { id, workspace_id, tx } +} + +/// Create a surface whose child process is spawned lazily: on the first +/// `Resize` (using its geometry) or after `SPAWN_FALLBACK` (using `def_*`). +/// Attaches received before the spawn get an empty-grid snapshot and a live +/// subscription; input received before the spawn is buffered and replayed. +#[allow(clippy::too_many_arguments)] +pub fn spawn_surface_deferred( + id: SurfaceId, + workspace_id: WorkspaceId, + spawn_spec: SpawnSpec, + def_cols: u16, + def_rows: u16, + hooks_active: bool, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, ) -> SurfaceHandle { let (tx, mut rx) = mpsc::channel::(64); let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); let actor_id = id.clone(); - let detect_id = id.clone(); tokio::spawn(async move { + let mut cols = def_cols; + let mut rows = def_rows; + let mut prebuf: Vec = Vec::new(); + let fallback = tokio::time::sleep(SPAWN_FALLBACK); + tokio::pin!(fallback); + + // Pre-spawn phase: hold the child until we know the real geometry. + let proceed = loop { + tokio::select! { + _ = &mut fallback => break true, + msg = rx.recv() => match msg { + Some(SurfaceMsg::Resize { cols: c, rows: r }) => { cols = c; rows = r; break true; } + Some(SurfaceMsg::Input(bytes)) => prebuf.extend_from_slice(&bytes), + Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); } + Some(SurfaceMsg::AttachSnapshot { reply }) => { + let sub = bcast.subscribe(); + let snap = snapshot_ansi(&GridSurface::new(cols, rows)); + let _ = reply.send((snap, sub)); + } + Some(SurfaceMsg::Close) | None => break false, + } + } + }; + if !proceed { + let _ = exit_tx.send((actor_id, 0)); + return; + } + + let mut spec = spawn_spec; + spec.cols = cols; + spec.rows = rows; + match PtyHandle::spawn(spec) { + Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, prebuf).await, + Err(_) => { let _ = exit_tx.send((actor_id, 127)); } + } + }); + + SurfaceHandle { id, workspace_id, tx } +} + +/// The surface actor's main loop: owns the PTY, fans output out to subscribers +/// through the batching `flush`, services input/resize/attach, and reports exit. +#[allow(clippy::too_many_arguments)] +async fn run_actor( + id: SurfaceId, + mut pty: PtyHandle, + cols: u16, + rows: u16, + hooks_active: bool, + bcast: broadcast::Sender>, + mut rx: mpsc::Receiver, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + prebuffered_input: Vec, +) { + let actor_id = id.clone(); + let detect_id = id; + if !prebuffered_input.is_empty() { + let _ = pty.write_input(&prebuffered_input); + } + + { let mut grid = GridSurface::new(cols, rows); let mut pending: Vec = Vec::with_capacity(FLUSH_BYTES); let mut flush_deadline: Option = None; @@ -137,9 +233,7 @@ pub fn spawn_surface( } let code = pty.wait(); let _ = exit_tx.send((actor_id, code)); - }); - - SurfaceHandle { id, workspace_id, tx } + } } /// Feed pending bytes into the grid, run detectors, broadcast output, and emit a @@ -280,6 +374,63 @@ mod tests { assert!(got.contains("RESPAWN"), "got: {got:?}"); } + fn stty_spec() -> SpawnSpec { + // `stty size` prints " " — lets a test assert the geometry + // the child actually started with. + SpawnSpec { + command: "/bin/sh".into(), + args: vec!["-c".into(), "stty size; sleep 0.3".into()], + cwd: std::env::temp_dir(), + cols: 80, + rows: 24, + env: vec![], + } + } + + async fn collect_until(sub: &mut broadcast::Receiver>, needle: &str, ms: u64) -> String { + let mut got = String::new(); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms); + while tokio::time::Instant::now() < deadline { + if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await { + got.push_str(&String::from_utf8_lossy(&b)); + if got.contains(needle) { break; } + } + } + got + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn deferred_spawn_uses_resize_geometry() { + let _serial = crate::test_support::serial(); + let (state_tx, _s) = mpsc::unbounded_channel(); + let (exit_tx, _e) = mpsc::unbounded_channel(); + let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx); + + let (rtx, rrx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap(); + let mut sub = rrx.await.unwrap(); + // Resize before the fallback fires -> child must start at 100x40. + handle.tx.send(SurfaceMsg::Resize { cols: 100, rows: 40 }).await.unwrap(); + + let got = collect_until(&mut sub, "40 100", 2000).await; + assert!(got.contains("40 100"), "expected '40 100' (rows cols), got: {got:?}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn deferred_spawn_falls_back_to_default_geometry() { + let _serial = crate::test_support::serial(); + let (state_tx, _s) = mpsc::unbounded_channel(); + let (exit_tx, _e) = mpsc::unbounded_channel(); + let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx); + + let (rtx, rrx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap(); + let mut sub = rrx.await.unwrap(); + // No resize: after SPAWN_FALLBACK the child starts at the default 80x24. + let got = collect_until(&mut sub, "24 80", 3000).await; + assert!(got.contains("24 80"), "expected '24 80' (rows cols), got: {got:?}"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn osc133_output_drives_state_detection() { let _serial = crate::test_support::serial();