From aff396eaa73e8a513f66ea0679c41da83af70f57 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:55:14 +0200 Subject: [PATCH] feat(mobile): close a pane from the phone The app could watch and drive a pane but never close one. Hold a pane's row in the list, or pick Close pane from the terminal's menu: a sheet says what will be stopped and on which machine, and only its danger button closes it. From the terminal the app goes back to the list, which drops the pane when the next tree arrives. On the wire this is a new one-shot Open::ClosePane { pane_id, machine }, answered Ok or Denied. The gateway does what `tty7 pane close` does: finds the workspace holding the pane, sends PaneClose, and hangs up the panes it reports removed, on the gateway's machine or through the desktop's existing link to a remote one. A gateway that predates the variant drops the stream, which the app reports as "too old to close panes from the phone". --- crates/tty7-gateway/src/daemon.rs | 33 +++++++++++++++++++++++ crates/tty7-gateway/src/serve.rs | 17 ++++++++++++ crates/tty7-gateway/tests/bridge.rs | 39 +++++++++++++++++++++++++++ crates/tty7-mobile-client/src/lib.rs | 23 ++++++++++++++++ crates/tty7-mobile-proto/src/lib.rs | 40 ++++++++++++++++++++++++++++ mobile/DESIGN.md | 2 +- mobile/PRODUCT.md | 2 ++ mobile/src-tauri/src/lib.rs | 18 +++++++++++++ mobile/src/api.ts | 5 ++++ mobile/src/main.ts | 40 +++++++++++++++++++++++++++- 10 files changed, 217 insertions(+), 2 deletions(-) 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 cdde4618..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 ? [ {