diff --git a/crates/tty7-gateway/src/daemon.rs b/crates/tty7-gateway/src/daemon.rs index cc042239..952f12ac 100644 --- a/crates/tty7-gateway/src/daemon.rs +++ b/crates/tty7-gateway/src/daemon.rs @@ -310,6 +310,39 @@ impl Backend for Daemon { } } + /// What `tty7 pane close` does: take the pane out of its workspace's tree + /// on the machine it lives on, then hang up every pane that removal left + /// with no tab — the pane itself, normally. + fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()> { + let tree = match self.request_on(machine, ControlRequest::MachineGet)? { + ReplyOk::MachineTree(tree) => *tree, + other => return Err(unexpected("MachineGet", &other)), + }; + let workspace = tree + .workspaces + .iter() + .find(|ws| ws.tabs.iter().any(|tab| tab.root.contains(pane_id))) + .map(|ws| ws.id) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "that pane is not open any more") + })?; + let removed = match self.request_on( + machine, + ControlRequest::PaneClose { + workspace, + pane: pane_id, + }, + )? { + ReplyOk::Panes(panes) => panes, + other => return Err(unexpected("PaneClose", &other)), + }; + let panes = self.panes_on(machine)?; + for pane in removed { + panes.kill(pane)?; + } + Ok(()) + } + fn running_panes(&self) -> Option> { let panes = PaneClient::local().list().ok()?; Some( diff --git a/crates/tty7-gateway/src/serve.rs b/crates/tty7-gateway/src/serve.rs index 9933a977..d1704063 100644 --- a/crates/tty7-gateway/src/serve.rs +++ b/crates/tty7-gateway/src/serve.rs @@ -69,6 +69,9 @@ pub trait Backend: Send + Sync + 'static { /// Closes a tab and ends its panes, keeping it to reopen where the /// machine can. fn close_tab(&self, machine: Option<&str>, workspace_id: &str, tab_id: &str) -> io::Result<()>; + /// Closes a pane as the desktop does: out of its tab (the tab too, if it + /// was the last pane there), and whatever runs in it ended. + fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()>; /// The panes this machine's server has running, when it can say. fn running_panes(&self) -> Option> { None @@ -233,6 +236,20 @@ async fn serve_stream( finish(send).await; Ok(()) } + Open::ClosePane { pane_id, machine } => { + let closed = { + let backend = backend.clone(); + tokio::task::spawn_blocking(move || backend.close_pane(machine.as_deref(), pane_id)) + .await + .map_err(io::Error::other)? + }; + match closed { + Ok(()) => write_msg(&mut send, &ok).await?, + Err(e) => write_msg(&mut send, &denied(&e.to_string())).await?, + } + finish(send).await; + Ok(()) + } Open::Diff { cwd, machine } => { let read = if machine.is_some() { Err(io::Error::other( diff --git a/crates/tty7-gateway/tests/bridge.rs b/crates/tty7-gateway/tests/bridge.rs index 75aaf56d..092b657c 100644 --- a/crates/tty7-gateway/tests/bridge.rs +++ b/crates/tty7-gateway/tests/bridge.rs @@ -28,6 +28,8 @@ struct FakeMachine { typed: Mutex>>>, /// Every lease request that reached the "daemon". leased: Arc>>, + /// Every pane closed, with the machine it was closed on. + closed: Mutex, u64)>>, } impl FakeMachine { @@ -48,6 +50,7 @@ impl FakeMachine { }, typed: Mutex::new(None), leased: Arc::default(), + closed: Mutex::default(), } } } @@ -153,6 +156,20 @@ impl Backend for FakeMachine { }) } + fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> io::Result<()> { + if machine == Some("me@gone:22") { + return Err(io::Error::other("the desktop's link to me@gone:22 is down")); + } + if pane_id != 1 { + return Err(io::Error::other("that pane is not open any more")); + } + self.closed + .lock() + .unwrap() + .push((machine.map(str::to_string), pane_id)); + Ok(()) + } + fn close_tab( &self, _machine: Option<&str>, @@ -509,6 +526,28 @@ async fn a_paired_phone_opens_a_tab() { assert!(err.to_string().contains("no workspace nope"), "{err}"); } +#[tokio::test(flavor = "multi_thread")] +async fn a_paired_phone_closes_a_pane() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + + within(session.close_pane(None, 1)).await.unwrap(); + within(session.close_pane(Some("me@build-box:22"), 1)) + .await + .unwrap(); + assert_eq!( + *rig.machine.closed.lock().unwrap(), + vec![(None, 1), (Some("me@build-box:22".to_string()), 1)] + ); + + let err = within(session.close_pane(None, 9)).await.unwrap_err(); + assert!(err.to_string().contains("not open any more"), "{err}"); + let err = within(session.close_pane(Some("me@gone:22"), 1)) + .await + .unwrap_err(); + assert!(err.to_string().contains("is down"), "{err}"); +} + #[tokio::test(flavor = "multi_thread")] async fn a_paired_phone_closes_a_tab() { let rig = Rig::new().await; diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index 16c2da3e..9d5cc0ed 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -249,6 +249,29 @@ impl Session { } } +impl Session { + /// Closes a pane, ending whatever runs in it. `machine` is where it runs, + /// as on [`Session::pane`]. The tree on the control stream shows it gone. + pub async fn close_pane(&self, machine: Option<&str>, pane_id: u64) -> Result<()> { + let ask = Open::ClosePane { + pane_id, + machine: machine.map(str::to_string), + }; + // `Ok` is the whole answer: the pane is closed by the time it comes. + let (mut send, _recv) = open(&self.conn, &ask).await.map_err(|e| { + if e.to_string().contains("without answering") { + anyhow!( + "tty7 on this computer is too old to close panes from the phone — update it" + ) + } else { + e + } + })?; + let _ = send.finish(); + Ok(()) + } +} + impl Session { /// Sends a file to the machine for a pane to be handed, returning where /// it landed. `machine` is where that pane runs, as on [`Session::pane`]. diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index aea32f50..9a334fd7 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -15,6 +15,8 @@ //! keystrokes up, and a few [`PaneEvent`]s beside them; //! - a one-shot [`Open::NewTab`] stream that starts a shell in a new tab and //! answers with a [`TabCreated`]; +//! - a one-shot [`Open::ClosePane`] stream that closes a pane and ends what +//! runs in it; //! - a one-shot [`Open::Upload`] stream that carries a file from the phone to //! the machine and answers with where it landed, an [`Uploaded`]; //! - a one-shot [`Open::Diff`] stream that answers with a git working tree's @@ -120,6 +122,19 @@ pub enum Open { #[serde(default, skip_serializing_if = "Option::is_none")] machine: Option, }, + /// Close a pane, as closing it on the desktop does: it leaves its tab (the + /// tab with it, when it was the last pane there) and whatever runs in it + /// is ended. One-shot: `Ok` once it is closed, or `Denied` with what went + /// wrong. The tree on the control stream shows it gone on its own. + /// + /// A gateway older than this variant cannot parse it and drops the stream + /// unanswered. + ClosePane { + pane_id: u64, + /// As on [`Open::Pane`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + machine: Option, + }, /// Put a file from the phone on the machine, for a pane to be handed its /// path. After `Ok` the phone sends the file as bytes frames and finishes /// its side; the gateway answers [`Uploaded`] once the file is written. @@ -668,6 +683,31 @@ mod tests { ); } + #[test] + fn a_close_names_only_the_pane_for_this_machine() { + let open = Open::ClosePane { + pane_id: 7, + machine: None, + }; + let wire = serde_json::to_value(&open).unwrap(); + assert_eq!( + wire, + serde_json::json!({"type": "close_pane", "pane_id": 7}) + ); + assert_eq!(serde_json::from_value::(wire).unwrap(), open); + + let remote = Open::ClosePane { + pane_id: 7, + machine: Some("me@box:22".into()), + }; + let wire = serde_json::to_value(&remote).unwrap(); + assert_eq!( + wire, + serde_json::json!({"type": "close_pane", "pane_id": 7, "machine": "me@box:22"}) + ); + assert_eq!(serde_json::from_value::(wire).unwrap(), remote); + } + #[test] fn an_upload_names_its_file_and_size() { let open = Open::Upload { diff --git a/mobile/DESIGN.md b/mobile/DESIGN.md index 258e8833..6bf27cc1 100644 --- a/mobile/DESIGN.md +++ b/mobile/DESIGN.md @@ -305,7 +305,7 @@ Borderless, accent-coloured, 44×44. The back link is a 26px chevron plus the pr - **Internal Padding:** comes from the rows (10px/12px/14px). ### Rows -The unit of every list. Avatar or tile, a title (17px/500) over a subtitle (14px, ink-2), and an ink-3 chevron. Title and subtitle each truncate to one line. A pane's subtitle leads with its coloured status word, then " · "-joined detail. Press fills cell-press instantly and fades back over 0.35s. A hibernated tab dims its avatar to 45% and carries the "Asleep" tag. +The unit of every list. Avatar or tile, a title (17px/500) over a subtitle (14px, ink-2), and an ink-3 chevron. Title and subtitle each truncate to one line. A pane's subtitle leads with its coloured status word, then " · "-joined detail. Press fills cell-press instantly and fades back over 0.35s. A hibernated tab dims its avatar to 45% and carries the "Asleep" tag. Holding a pane row (half a second, still) asks to close that pane in a sheet with a danger button; the lift that ends the hold does not open the pane. ### Avatars and the status badge (signature) A pane is a 38px circle (30px in the terminal header): neutral (ink at 7% over cell) with a terminal glyph for a plain shell, or the agent's brand field with its mark in white (or the agent's own ink). The status badge is a 14px dot at the bottom-right, ringed 2.5px in the cell colour so it cuts into the avatar, exactly as on a desktop tab. Working blinks (1.4s, stepped); Waiting has the white hole; Done is solid; Idle has no badge. diff --git a/mobile/PRODUCT.md b/mobile/PRODUCT.md index fed528b3..37add29d 100644 --- a/mobile/PRODUCT.md +++ b/mobile/PRODUCT.md @@ -50,6 +50,8 @@ connects peer-to-peer over iroh by public key, end-to-end encrypted. | / ~ -) and a compose box that sends a whole message, then Enter. A copy view shows the pane's text for native selection. - Forget a machine (requires re-pairing). +- Close a pane on the machine — hold its row, or Close pane in its ⋯ menu — after a + confirmation, since it ends whatever runs there; the desktop loses it too. - No framework: vanilla TypeScript with a tiny DOM helper; xterm.js for the terminal. - Not yet: QR scanning, push notifications, keychain storage. diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index b2465ee7..14d0f5e2 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -309,6 +309,23 @@ async fn tab_new( .map_err(err) } +/// Closes a pane on the machine for good, ending whatever runs in it — not +/// [`pane_close`], which only stops this phone watching one. The tree drops +/// it on its own. +#[tauri::command] +async fn pane_kill( + state: State<'_, Arc>, + host_id: String, + machine: Option, + pane_id: u64, +) -> CmdResult<()> { + let session = state.session(&host_id).await?; + session + .close_pane(machine.as_deref(), pane_id) + .await + .map_err(err) +} + /// Closes a tab and its panes; the machine keeps it to reopen where it can. #[tauri::command] async fn tab_close( @@ -588,6 +605,7 @@ pub fn run() { refresh, tab_new, tab_close, + pane_kill, upload, diff, pane_open, diff --git a/mobile/src/api.ts b/mobile/src/api.ts index ef3452e2..bb4a7c6b 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -162,6 +162,11 @@ export const tabNew = ( export const tabClose = (hostId: string, machine: string | null, workspaceId: string, tabId: string) => invoke("tab_close", { hostId, machine, workspaceId, tabId }); +/** Closes a pane on the machine, ending what runs in it. Not `paneClose`, + * which only stops this phone watching one. */ +export const paneKill = (hostId: string, machine: string | null, paneId: number) => + invoke("pane_kill", { hostId, machine, paneId }); + export const paneInput = (handle: number, data: string) => invoke("pane_input", { handle, data }); diff --git a/mobile/src/main.ts b/mobile/src/main.ts index acb2c333..b8f8838c 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -1986,11 +1986,29 @@ interface TabRef { id: string; name: string; busy: boolean; + /** Split into more than one pane: one of them can be closed alone. */ + split?: boolean; } function tabRef(ws: WorkspaceView, tab: TabView, name: string): TabRef { const busy = tab.panes.some((p) => p.agent && (p.agent.status === "working" || p.agent.status === "waiting")); - return { workspace: ws.id, id: tab.id, name, busy }; + return { workspace: ws.id, id: tab.id, name, busy, split: tab.panes.length > 1 }; +} + +/** Closes one pane of a split tab on the machine, asking first: whatever runs + * there is ended, and the desktop loses it too. False when it was not + * closed: declined, or refused, which `failed` is told. */ +async function closePane(host: Host, place: Place, pane: PaneView, title: string, failed: (message: string) => void) { + const what = pane.agent ? `${agentLook(pane.agent.kind).name} and anything else running in it` : "Whatever runs in it"; + if (!(await confirmSheet(`Close this pane of ${title}?`, `${what} will be stopped, and it closes on ${place?.name ?? host.name} too.`, "Close pane"))) + return false; + try { + await api.paneKill(host.id, place?.key ?? null, pane.id); + return true; + } catch (e) { + failed(sentence(errorText(e))); + return false; + } } /** Closes a tab, asking first when an agent in it is at work. False when it @@ -2177,6 +2195,13 @@ function paneRow(host: Host, place: Place, ws: WorkspaceView, tab: TabView, pane }, ); } + if (tab.panes.length > 1) + actions.push({ + label: "Close this pane", + icon: "close", + danger: true, + run: () => void closePane(host, place, pane, name, (message) => closeFailed(name, message)), + }); actions.push({ label: "Close tab", icon: "close", @@ -2594,6 +2619,19 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, icon: "phone" as const, run: toggleTake, }, + ...(tab?.split + ? [ + { + label: "Close this pane", + icon: "close" as const, + danger: true, + run: async () => { + // Nothing is left here to watch; the list drops it on its own. + if (await closePane(host, place, pane, tab.name, (message) => showBanner(message))) hostScreen(host); + }, + }, + ] + : []), ...(tab ? [ { @@ -2906,6 +2944,17 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, return true; }); + // Whether the program asked for SGR mouse reports (`?1006`), the encoding + // a swipe over a full-screen program is reported in; xterm keeps it to + // itself. A replay restores it along with the other modes. + let sgrMouse = false; + const mouseEncoding = (on: boolean) => (params: (number | number[])[]) => { + if (params.includes(1006)) sgrMouse = on; + return false; + }; + term.parser.registerCsiHandler({ prefix: "?", final: "h" }, mouseEncoding(true)); + term.parser.registerCsiHandler({ prefix: "?", final: "l" }, mouseEncoding(false)); + let handle: number | null = null; // Whether keystrokes land: set by a successful open, cleared by anything // that says they no longer do. @@ -3247,17 +3296,36 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (document.activeElement === typing) typing.blur(); else typing.focus({ preventScroll: true }); }; - typing.addEventListener("focus", () => keyboard.classList.add("on")); - typing.addEventListener("blur", () => keyboard.classList.remove("on")); - - // Committed text goes to the pane and the field is emptied again; while - // an input method is composing, the field holds the candidate. - let imeOpen = false; - const flush = () => { - if (imeOpen || !typing.value) return; - const text = typing.value.replace(/\r?\n/g, "\r"); + // While the field has the keys the pane's cursor is drawn solid, as a + // focused terminal's is, so a tap on the pane shows where typing lands. + typing.addEventListener("focus", () => { + keyboard.classList.add("on"); + term.options.cursorInactiveStyle = "block"; + }); + typing.addEventListener("blur", () => { + keyboard.classList.remove("on"); + term.options.cursorInactiveStyle = "outline"; typing.value = ""; - send(text); + typed = ""; + }); + + // Committed text goes to the pane as the field changes; while an input + // method is composing, the field holds the candidate and nothing is sent. + // The field is not emptied while it has the keys: iOS keeps its own copy + // of the text, and a field cleared under it leaves the input method + // stuck after the first character it commits. What goes out is the + // change since the last send: characters taken off the end as DEL, one + // each, then what is new. + let imeOpen = false; + let typed = ""; + const flush = () => { + if (imeOpen || typing.value === typed) return; + const was = Array.from(typed); + const now = Array.from(typing.value); + let same = 0; + while (same < was.length && same < now.length && was[same] === now[same]) same++; + typed = typing.value; + send("\x7f".repeat(was.length - same) + now.slice(same).join("").replace(/\r?\n/g, "\r")); }; typing.addEventListener("compositionstart", () => (imeOpen = true)); typing.addEventListener("compositionend", () => { @@ -3466,6 +3534,38 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, latest.hidden = true; requestAnimationFrame(showCursor); }; + // A full-screen program (an agent's full-screen view, less, vim) is on + // the alternate screen, which keeps no scrollback: there is nothing here + // to scroll, and the program scrolls itself. A swipe over it turns the + // mouse wheel, as on the desktop, a notch a row: reported at the finger + // to a program that asked for the mouse, arrow keys to one that did not. + let wheelPx = 0; + let finger = { x: 0, y: 0 }; + const wheel = (dy: number) => { + wheelPx += dy; + const row = rowHeight(); + const notches = Math.trunc(wheelPx / row); + if (!notches) return; + wheelPx -= notches * row; + const up = notches > 0; + let notch: string; + if (term.modes.mouseTrackingMode === "none") { + notch = `\x1b${term.modes.applicationCursorKeysMode ? "O" : "["}${up ? "A" : "B"}`; + } else { + const drawn = screenEl.querySelector(".xterm-screen")?.getBoundingClientRect(); + const clamp = (n: number, max: number) => Math.min(Math.max(1, n), max); + // The pane's own rows: the ones under them here are history. + const x = clamp(drawn ? Math.floor(((finger.x - drawn.left) / drawn.width) * term.cols) + 1 : 1, term.cols); + const y = clamp(drawn ? Math.floor((finger.y - drawn.top) / row) + 1 : 1, Math.min(paneRows, term.rows)); + const button = up ? 64 : 65; + // The legacy encoding's bytes past 127 would not survive the trip as + // text; it is capped there. + notch = sgrMouse + ? `\x1b[<${button};${x};${y}M` + : `\x1b[M${String.fromCharCode(32 + button, 32 + Math.min(x, 95), 32 + Math.min(y, 95))}`; + } + void input(notch.repeat(Math.abs(notches))); + }; // Moves the view by a distance in pixels. Dragging down goes back in the // scrollback. const scrollBy = (dy: number) => { @@ -3479,6 +3579,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, screenEl.scrollTop -= dy; return; } + if (buf.type === "alternate") { + if (frac) setFrac(0); + wheel(dy); + return; + } const row = rowHeight(); // Where the view is, from the top of the scrollback, and where it goes; // the bottom is the prompt with nothing shifted. @@ -3506,6 +3611,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if ((e.target as Element).closest?.(".scrollbar")) return (touch = null); const t = e.touches[0]; touch = { x: t.clientX, y: t.clientY, axis: null, samples: [[t.clientY, e.timeStamp]] }; + finger = { x: t.clientX, y: t.clientY }; + wheelPx = 0; }, { passive: true }, ); @@ -3522,6 +3629,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, } if (touch.axis !== "y") return; e.preventDefault(); + finger = { x: t.clientX, y: t.clientY }; const last = touch.samples[touch.samples.length - 1][0]; pending += t.clientY - last; touch.samples.push([t.clientY, e.timeStamp]); @@ -3917,6 +4025,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, replayed = true; loaded(); term.reset(); + sgrMouse = false; }; live = false; retry.cancel();