refactor: one client shell on one host pipe across desktop, iOS, Android and browser - #347
Open
Tryanks wants to merge 58 commits into
Open
refactor: one client shell on one host pipe across desktop, iOS, Android and browser#347Tryanks wants to merge 58 commits into
Tryanks wants to merge 58 commits into
Conversation
…pipe The desktop window reached around `HostLink` for two things. Import progress rode a construction-time `ImportRoutes` bus keyed by request id, and the command palette opened its own `SessionStore` to search session content. Both are now ordinary protocol traffic, so a remote client and the desktop window see the same behaviour. Import is replicated host state, not a request-id stream. `HostMux` rewrites message ids and drops the response route on Ack, so progress cannot ride the originating request. `Topic::ExternalImport` plus `ServerEvent::ExternalImportStatusReplaced` carry a host-generated `run_id` and a Progress/Finished state; only the latest run per project is retained, and it is cleared with the project. Clients subscribe before sending `StartExternalImport`, so a run short enough to finish first is still recoverable from the subscription snapshot. Completion is runtime-owned: the importer finalizes the index in one mailbox turn and publishes `Finished` from the next, so the index replacement always reaches a client first, and finalization survives the initiator disconnecting. A second live run on the same project is refused with an `import_in_progress` error. `Command::FinishExternalImport` and its UI intent are gone; the client no longer drives finalization. Search moves to `Query::SearchSessionContent` / `QueryResponse::SessionContentHits`. One host-owned `SessionSearch` is bound to that host's store, searches host-owned session metadata in index order, clamps the limit to 50, and does its file reads and cache locking on the blocking executor. The palette keeps only its debounce and the generation guard, and now offers content hits on every client build rather than only under `feature = "desktop"`. `SessionSearchHit` and the import progress shape move to tcode-protocol so the service, the host and the palette share one owner instead of the palette maintaining an imitation. Manifest: tcode-ui declares `tcode-remote` under `cfg(not(target_family = "wasm"))`. The `remote-client` feature reaches smol -> rustix -> errno, which has no wasm32-unknown-unknown build; declaring the dependency per-target keeps `cargo check -p tcode-web --target wasm32-unknown-unknown` green even when the feature is on.
The terminal was the last privileged side channel: the desktop window read `term::Terminal` in-process through `LocalAffordances`, while remote clients replayed a 256 KiB byte ring into their own emulator. That replay was not faithful — a late subscriber lost modes evicted from the window, its cursor style, and its rows were offset after a resize — and no checkpoint serializer can fix it from rio's public state. The host is now authoritative for the grid. It projects `TerminalFrame` on subscribe and coalesced `TerminalDelta`s afterwards, both built from the same snapshot sequence, so a client attaching at any moment gets frame N then deltas N+1…: never a duplicate row, never a gap. Cells arrive resolved (text with combining marks, wide/spacer, interned style, OSC 8 target, per-row wrapline), alongside cursor, raw mode bits, retained scrollback and image placements. Clients keep only what is genuinely theirs: scroll offset, selection, link hit-testing, and input encoding from the replicated mode bits. Desktop and remote are identical by construction, and the drawer now works on every client including Android and the browser, which previously had no terminal at all. Bandwidth: `yes | head -c 2000000` (2 MB of PTY output) costs a 3.4 KB frame plus 36 deltas totalling 36 KB — row-level diffing keeps a scrolling screen cheap, and scrollback that outruns its budget is republished once when the burst settles rather than streamed row by row. Row-level replace is the deliberate simplification; cell-run deltas are the upgrade if it matters. Placement geometry moves to the host, computed once against a viewport anchored at the oldest retained row using the client's own cell size, so a client only translates by its scroll offset and clips. That deletes the client's placement layout entirely and lets `term::mappings` and the Mode/KeyboardModes bit types move to tcode-protocol, where they belong: they are pure functions of the wire, not of an emulator. Deleted: `ServerEvent::TerminalOutput` and the byte ring, `LocalAffordances`, `WorkspaceStore::attach_local`/`new_local`, the exported `LocalTerminalRegistry` (now host-private), the `local-host` feature, and `ClientTerminal::Local`. `term` gains a `pty` feature so clients link no PTY code, and `tcode-ui` no longer depends on `tcode-runtime`. `crates/ui` still depends on `term` behind a new `command-ansi` feature, off by default for mobile and the browser: the chat command panel replays *stored* command output through a local emulator, and rio-vt does not compile for Android (its kitty graphics path calls `shm_open`, absent from bionic). That is why the portable command panel survives.
Viewport and selection never cross the wire, so independence is structural. This pins it: two models fed the same frame scroll and select apart.
The UI swapped whole modules on `feature = "desktop"` — Add Project, Settings, thread export and Preview each had a stub twin, and the provider/ACP/orchestrate views were compiled away entirely — even though almost all of that is replicated store state. What is actually native is much smaller: a file dialog, an embedded webview, macOS permission grants, dictation, the AppKit pasteboard and launching an editor. Each of those is now its own feature, `desktop` is their aggregate, and no view is gated at all. Where an operation is unavailable the view says which machine can perform it and offers what it can, and it asks two separate questions first: does this build have the capability, and does it apply to this attachment? A directory picker that browses this machine is withheld over a remote link even where the build has one. Behaviour that was wrong for a remote attachment moves to the side that owns it: - A project root is validated by the host against its own filesystem and path rules, and the dialog shows the host's reason for refusing one. The client no longer judges a host path with `Path::is_absolute`, and `Query::IsDirectory` is gone with the client-side check it served. - Thread export splits rendering from writing. `Query::RenderThreadExport` returns the complete artifact with a client-safe file name; delivery is the client's — save panel, browser download, or copy — and nothing is written on the host. `Command::ExportThread` and its notice/error are removed. - Preview keeps its URL field, open-externally and copy-URL everywhere. Without a system webview it explains itself, answers automation with an explicit unsupported instead of timing out, and does not subscribe as an owner of the session's preview. Localhost port discovery is local-only. - Settings shows every section remotely; only the TCC permission group is local, and it says to manage them on the named host otherwise. Editable fields wait for the host's first snapshot instead of showing local defaults, and a field the user has edited survives every later snapshot. - Settings → Remote splits: connecting (saved hosts, discovery, pairing, certificate repair) needs only `tcode_client` and compiles everywhere; hosting keeps the listener and beacon behind `remote-hosting`. The pairing rules the phone already had — endpoint-bound pins, generation-stamped results, fixed origins — are now one shared form. `tcode-services` and `tcode-remote` leave tcode-ui's normal dependencies: the editor launcher and the WebView2 data directory are injected from the composition root, and the workspace-relative path helper is one std::path implementation that never touches the client filesystem for a host path. Fixed desktop viewport heights and dialog sizes are capped against the window, and window-drag regions sit behind a window capability rather than a page gate.
…ery and preview capability
One rule replaces the platform test: compact iff the viewport minus the system's horizontal occlusion is under 900px. Nothing else decides it — not the operating system, not the input device, not a stored preference — so a desktop window dragged narrow and an iPad in portrait get the same answer, and the answer is never persisted. `WindowSeam` is where the window's own edges live: which parts the system covers and where the software keyboard is. That is a property of the window, not of the host the workspace is attached to, so it is deliberately not part of `ClientHost`. Its one content rectangle is shared by pages, dialogs and toasts, and its bottom avoidance is max(safe area, keyboard) — a keyboard that already covers the home indicator does not get counted twice. Platform backends schedule a frame when their insets move, so the seam is read per frame instead of polled. Crossing the breakpoint must not destroy user state, so the composer now follows the layout in place. Rebuilding it discarded the draft, its selection and its pending attachments; the placeholder also had two owners fighting each frame in compact, and now has one. Whether Enter submits follows the keyboard rather than the width, because a wide tablet still types on glass and a narrow desktop window still has a hardware Enter key. A compact window on the desktop is still a window: its top strips inset past the native macOS traffic lights and host the caption cluster where the platform expects it.
`AppShell` is now the window: it owns navigation, the back stack and the layout rule, and it survives everything below it. Beneath it sits at most one attachment — a link to a host and the views over that host's workspace — so three states are possible: no attachment (the hosts list), a local one, and a remote one. Switching hosts replaces the attachment and nothing above it; the window's navigation root stays put, and resizing never touches the attachment at all. Under the breakpoint the split becomes a stack: hosts, threads, thread, panel. Each destination is the surface the wide layout already uses — Settings → Remote is the hosts list, the shared sidebar is the thread list, the shared chat view is the thread, and the terminal, diff, plan and preview are the same entities the split puts on the right. A compact window has less room, not less product. New thread starts a draft where the host has one project and otherwise opens the palette, which already owns "new thread in <project>" and can search it. Back has one order everywhere: keyboard, then the topmost dismissible overlay, then the settings route, then the panel destination, then the stack. It reports "not consumed" only at the root. The handle is scoped to the window the gesture arrived at rather than to whichever window happens to be first. The attachment owner moves into tcode-ui and stops blocking: teardown hands back the retiring pump's completion as a value to await instead of joining it on the UI thread, which a browser — running the whole client on one executor — cannot do at all. Both regression tests move with it. Every entry point is now platform bootstrap plus `run_shell`: a `ClientHost`, a window seam, and a window. The desktop keeps what is genuinely its own — the local kernel, the listener, the quit prompt and its launch flags — and supplies local attachment as one closure. That leaves nothing in `crates/mobile`, so it is gone, along with the locale keys only it used; its phone preview is now a tcode-ui example that opens the same shell at phone geometry.
The layout rule, the compact destinations, the back order, the one insets rule and what a breakpoint crossing must preserve now live in the design spec, where the rest of the visual contract is. `docs/mobile-design.md` described a shell that no longer exists; its still-valid touch, navigation, connection and input contracts are folded into "Compact layout" and the file is gone. The remote guide stops calling the browser and phone a separate compact client: they run the same app, and what actually differs between clients is which operations they can perform locally, which is the capability rule the design spec already states.
The built-in terminal renders text. Sixel, iTerm2 and kitty graphics are gone: rio-graphics, rio-vt's `graphics` feature, the image and overlay wire types, their host projection and the client's image registry and overlay painting all go with them. rio-vt now lives only on the host. Stored command output in the chat timeline is re-rendered there through the same `GridEmulator`: a client measures the width it can show and asks `Query::RenderStoredOutput` for that item at that many columns, and gets back one `TerminalFrame`. `term` leaves tcode-ui's dependencies entirely, and with it the `command-ansi` feature and the plain-text fallback panel that existed because Android could not link rio. The panel shows the raw text until the host answers, caches the answer per width, debounces a width drag by 120ms and holds at most four renders in flight so scrolling a long thread cannot flood the host. An answer for a width the panel has left is dropped by generation.
The host was both phone-only and portrait-only, so UIKit could not deliver rotation geometry to GPUI. Make the app universal, declare the device-specific orientation masks, and refresh keyboard/safe-area geometry before forwarding resized bounds.
…7a0-8238-2a5599547a95 # Conflicts: # crates/app/src/preview_smoke.rs # crates/ui/Cargo.toml # crates/ui/src/preview_panel.rs
…-pair plan is authoritative
The window kept three competing navigation authorities: a compact `Destination` addressed by depth, a `route` field, and the settings page's own `compact_detail`. Back could only unwind the first, Settings closed outright, and walking back to the hosts list detached the window from its host. Compact wore three header styles and two Back positions. `WindowState` now owns one history. Every destination — Hosts, Pair, Threads, Thread, Panel, Settings and one settings section — is an entry in it; the compact stack mirrors it entry by entry, and the wide route is derived from whichever entry is on top. One `back` dispatcher pops that history for the toolbar control and the Android gesture alike, so Settings section → Settings → the page it was opened from, and Pair → whatever pushed it, need no special cases. Navigating never detaches: only connecting to a different host (which restarts the history at that host's threads) or an explicit Disconnect does. Hosts leaves Settings. It is a product surface answering one question — which host am I talking to — reached from a new persistent feature area in the sidebar at both widths, wide as a route beside the sidebar and compact as the navigation root. Its rows are touch-sized: name, endpoint, last-connected time, a status glyph for the attached host, and a menu carrying Disconnect and Forget. Pairing is its own page with labels above full-width fields and the primary action pinned above the keyboard. Settings → Remote keeps hosting this computer, and only where the client can host at all. Switching hosts from the UI never worked: `run_shell` took the shell out of the cell the switcher closure held, so every switch was a silent no-op. Both switching and detaching now resolve the window's shell through the same window-scoped handle Back uses. Compact settings rows stack their label and description above a full-width control, so prose wraps instead of overflowing.
Record the contract the code now keeps: one nav bar on every compact page including Settings, one history that Back unwinds a step at a time, and navigation that never detaches. Describe Hosts and Pair, the sidebar's feature area and its row anatomy, and the compact rule that puts a row's label above its control. Settings → Remote is hosting this computer; connecting is the Hosts surface, so README and the remote guide point there instead.
The compact Panel destination mounted the desktop right panel verbatim: a second tab row under the segmented control, the column's expand / split / close cluster, and bodies flush to the window edges with git errors cut off mid-word. Compact now owns the page. The segmented control is the only selector, so the diff panel's tab row and window controls are not built at all, and Preview drops its close button — Back leaves the page. What is left is one toolbar row on the page inset with 44pt touch targets; the diff's view toggles, the terminal's split/new/restart/close and the preview's URL actions move into an overflow DropdownMenu. Diff, Plan and Preview bodies take the 16pt page inset; notices wrap inside it and code scrolls sideways within the body instead of off the page. The terminal grid stays edge to edge, as it is measured in columns. Two overflows the audit turned up on the way, both at both widths: a work-log detail (`w_full` + `ml_2`) is 8pt wider than the column it sits in, so the inline diff and command output ran past the page — that indent is now padding on a full-width wrapper (`material::rail_detail`); and Settings → Other devices kept desktop two-column rows, squeezing its descriptions to a word per line, so it now follows the same rule `SettingsPage::row_frame` applies. docs/DESIGN.md records the rule: page inset 16pt, card inset 12pt, terminal exception.
…tings Back names a kind of place, not the page under it: every destination owns one short fixed label, so a long thread title can no longer truncate a Back control into "‹ Build M…" and the nav bar reserves the same room on every page. The compact Threads page is titled Threads with the attached machine as its subtitle. One list style across the product surfaces: threads, machines and nearby machines are plain rows at the page inset with hairline separators and one caption style, while settings-like forms keep the grouped card. A machine row's overflow trigger now sits inside the row, so the hover fill covers the whole row instead of stopping short of a seam beside it. Wide Settings keeps the workspace sidebar, as the Machines route already does: it replaces the content column, its header carries the same Back control, and the sidebar's Settings entry shows the selected surface while the route is showing. The rail's duplicate wordmark and its back row are gone with it.
…ttings Opening Settings switches the whole window again, as it did before the sidebar was put beside it: the left column is the settings rail, which carries the wordmark, clears the platform's window controls, groups its sections under the two captions and ends in the row that leaves the route. Machines keeps the sidebar; that one is meant to be persistent. Computer Use and Browser configure a screen the agent drives, so they move to the end of the machine group under a collapsible Advanced disclosure — collapsed on a client attached elsewhere that drives no screen of its own, expanded wherever it does. A deep link to one of them opens the disclosure rather than selecting a hidden section. A project header separates one project from the next, so the compact thread list draws it only from the second project onwards. "This machine" now reads as the row it sits among: title over one muted subtitle, no leading icon, the connection dot in the same slot. The terminal key bar orders its symbol tail by how often a shell line needs the character and adds one-tap ^C and ^D after the arrows. A combo carries its own Control through the same key_bytes path a hardware Ctrl+C takes, and consumes any sticky modifier rather than doubling it.
Tryanks
force-pushed
the
unify-client-shell
branch
from
September 7, 2026 20:47
89e1df3 to
a0f4181
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The kernel and the UI already talked through one NDJSON pipe (
tcode_client::HostLink), but the desktop window kept three privileged side channels around it and the phone/browser builds re-implemented a second client on top of the same pipe. This PR removes both, so every host runs the same client and the layout is decided only by viewport width.Topic::ExternalImport,Query::SearchSessionContent). The terminal is host-authoritative: the host projects its grid asTerminalFrame/TerminalDelta, the client keeps viewport and selection locally and runs no emulator.LocalAffordances,LocalTerminalRegistry, the 256 KiB byte-replay ring and thelocal-hostfeature are gone; late attach now reproduces the host grid exactly (regression tests incrates/runtime/src/terminal_replication_tests.rs).tcode_client::host::ClientHost(GPUI-free) withtcode_remote::NativeClientHostshared by desktop, iOS and Android, andWebHostfor the browser.MobileHostand the desktop-only copies of pairing/connect/host persistence are deleted.crates/ui/src/attachment.rsowns a store and its views per link; switching host replaces them in place while the local kernel, mux, listener and beacon keep running.RemoteMode, the relaunch-with---connectflow and "connect after restart" are gone. Appearance, language and device name are client-owned preferences with client-over-host precedence.*_portable.rsstubs are deleted; native operations are narrow features (native-dialogs,native-preview,voice,local-permissions,pasteboard-image,remote-hosting) with runtime capability checks. Thread export is now a host query delivering bytes to the client (Save / Download / Copy), project roots are validated by the host, preview ownership is capability-aware, andtcode-services/tcode-remoteleave the portable UI dependency set.AppShell. Compact iff usable content width < 900 px, driven by a singleobserve_window_boundssubscription; compact navigation (Hosts → Threads → Thread → Panel) lives inAppShellusinggpui_base::NavStack; aWindowSeamapplies safe-area/IME insets once; Android Back routes through the shell. iOS, Android, web and desktop all calltcode_ui::run_shell.crates/mobileanddocs/mobile-design.mdare deleted; the phone preview iscargo run -p tcode-ui --example phone.Docs updated:
docs/DESIGN.md(layout rule, compact layout, capability rule, export/import contracts),docs/remote.md, README, CONTRIBUTING.Verification
On the final commit:
cargo fmt --check,cargo clippy --workspace --all-targets --locked -D warnings,cargo build --workspace,cargo test --workspace(0 failed),cargo machete,cargo check -p tcode-ui --no-default-features, iOS sim / wasm32 / Android NDK checks with-D warnings. The wasm dependency tree contains no tcode-remote, smol, tungstenite or rio-vt.Manual: desktop default window unchanged; window dragged below 900 px enters the compact stack and returns with the same thread and draft; in-place pair + connect to a headless host, remote add-project/export/settings behave per capability; browser wide and narrow both render the shell against a headless host (
tcode-headless --features web); iPhone simulator compact, iPad simulator wide split, IME insets on iOS and Android, Android Back chain through the shell.Known gaps
cx.quit()is called as before, so this looks like a pre-existinggpui-androidquit issue.command-ansifeature), which does not compile on Android (upstreamshm_open); the portable text renderer remains for that one panel.Follow-ups landed on this branch after the first description
tcode-headlessprintshttps://host:port/#code=NNNNNNand/admin/pairreturnsbrowser_url; the page pairs from the fragment once and clears it. Also fixed a wasm panic in the terminal drawer (std::time::Instant).Query::RenderStoredOutput), so no client build links rio-vt, including Android. Net −493 lines.nav_barwith top-left Back on every compact screen including Settings; navigation is a shell-owned history with one Back dispatcher (toolbar and Android Back). Hosts moved out of Settings into a persistent sidebar feature area under Search (both layouts; wide rendersRoute::Hostsbeside the sidebar). Settings → Remote keeps only hosting settings. Hosts/Pair are phone-native lists and a stacked form; navigating never detaches, only switching or an explicit Disconnect does.main(feat(preview): report failed navigations to the MCP tools #350 preview load errors, ci: scope checks to affected Rust packages and platforms #351 CI scoping). CI note: cargo-rail 0.25.0 rejects a--sinceplan whose diff contains a rename; the plan job now tolerates that and relies on the exact--from/--toplan it already computes.