feat(control): surface/tab lifecycle over the control socket#115
feat(control): surface/tab lifecycle over the control socket#115tairea wants to merge 4 commits into
Conversation
Two defects made limux undrivable from the control socket. Both are fixed here, and together they close the four-verb loop (send / send-key / read-screen / identify) that agent orchestration depends on. 1. send-key never reached the PTY. Terminal::send_key synthesized a ghostty key event with `keycode: 0`. Ghostty resolves the *physical* key from the hardware keycode and does not fall back to the keyval, so every injected key arrived as an unidentified key and was dropped. The caller still got `OK`, so the failure was silent: `limux send "echo hi"` followed by `limux send-key enter` left the text sitting at the prompt forever. No command sent over the socket could ever execute. Map the keyval back to a real keycode through the display's keymap (the inverse of the existing keyval_unicode_unshifted) so socket keys are encoded exactly like keys typed by a human. 2. Panes in a background workspace never realized. A pane only realizes its ghostty surface once its workspace is displayed. Splits made in a workspace that isn't selected stay unrealized: surface-health reports healthy=false and send_key returns false, which the bridge reports as the misleading "unsupported key". The host has always implemented `workspace.select` — it simply was not reachable from the CLI, so a CLI-driven fleet could not bring its own workspace forward and could never start. Expose it as `limux select-workspace --workspace <id|ref>`. Verified end to end on Wayland: create a workspace, select it, split a 2x2, launch an interactive REPL in one pane, prompt it over the socket and read the answer back off the screen.
bvolpato
left a comment
There was a problem hiding this comment.
Requesting changes for three control-path regressions:
- Explicit pane IDs use global lookup, so workspace A requests can create or focus panes in workspace B and return mismatched workspace metadata.
focus-surfaceandfocus-panereturn OK for background workspaces without selecting them, leaving visible focus unchanged.- Socket rename/pin bypass UI updates and session-save callbacks. Pin visuals stay stale, changes may not persist promptly, and empty rename leaves old title visible.
Live GTK routing approach looks right. Root-scoped pane resolution plus shared GUI mutation paths should close these.
(Review assisted by gpt-5.6-sol)
Addresses @bvolpato's review. He is right, and the fix was incomplete. A socket-injected key needs BOTH halves of the ghostty key event, and supplying only one drops it silently: keycode only -> `limux send-key a` returns OK and writes nothing text only -> Enter, arrows, F-keys and ctrl-chords cannot be encoded The original commit fixed the first half (keycode 0 meant ghostty saw an unidentified physical key and dropped the event), which made Enter and arrows work and closed the four-verb loop. But `translate_key_event` still left `text` null, so ordinary printable input had nothing to write and vanished exactly as before. It went unnoticed because orchestration uses `send` for text and `send-key` only for Enter. The codebase already says so, 550 lines above the bug, on the GTK key controller: // Send key events with the text field populated. Ghostty uses the // text field for actual character input and the keycode for bindings. So: mirror the GTK path. Populate `press.text` from the existing `key_event_text` helper, keeping the CString alive across `ghostty_surface_key`; leave the release event textless, as the GTK controller does. The GTK path sources this text from the input method -- a socket-injected key has no IM behind it, so derive it the same way GTK's own fallback does. `key_event_text` returns None for control characters, and that is load-bearing rather than incidental: Enter and ctrl-chords must be *encoded* by ghostty from key + mods, not written as literal bytes. Writing "\r" as text instead of letting ghostty encode Return is how you break the kitty keyboard protocol. This makes am-will#112 and this PR complementary rather than alternatives -- am-will#112 supplies the text half, this supplies the physical-key half, and both are required. Verified live on Wayland, all four classes at once, because fixing one by breaking another is the obvious failure mode here: printable `send-key e,c,h,o,space,h,i` -> `echo hi` appears at the prompt Enter `send-key enter` -> it executes (once) arrows `send-key up` then enter -> history recalled; it executes twice ctrl-chord `send-key '<ctrl>c'` -> ^C interrupts a running `sleep 60` Regression test pins the contract for all three key classes.
Routes the surface and tab lifecycle through the GTK bridge. Three of these methods were already advertised in `limux --help` and in the CLI, but the bridge answered `-32601: unknown method`, so they looked supported and were not: limux new-surface -> -32601: unknown method: surface.create limux rename-tab -> -32601: unknown method: tab.action limux tab-action -> -32601: unknown method: tab.action And there was no way to close a pane at all: `close-workspace` was the only teardown verb limux had, so an orchestrator could not reclaim one worker without destroying the whole workspace. The GUI already implemented all of this (`close_tab_in_pane`, `activate_tab_in_pane`, `add_terminal_tab_to_pane`, `add_browser_tab_to_pane_with_uri`, `focus_active_tab_in_pane`); it simply wasn't reachable. This wires it up rather than reimplementing it. Methods now routed by the bridge: surface.create (new-surface) terminal or browser tab in a given pane surface.close (close-surface) close one tab; pinned tabs refuse surface.focus (focus-surface) make a tab active and focus it pane.focus (focus-pane) move focus to a pane tab.action (tab-action) rename | pin | unpin | select | close New CLI verbs: `close-surface`, `focus-surface`, `focus-pane`. `new-surface` now forwards `--pane`, `--type` and `--url` instead of sending an empty params object. `rename-tab` and `tab-action` need no CLI change — they were always calling `tab.action`; it just answers now. pane.rs gains three primitives, following the existing helper style: `rename_tab_in_pane`, `set_tab_pinned_in_pane`, and `locate_surface_in_root` (resolve a surface hint to its owning pane + tab). Tests: bridge-level routing and parse tests, which need no GTK display. Verified live on Wayland: rename a tab, add a second tab to a pane, pin it, confirm close is refused (-32009), unpin, close it, and watch the pane collapse when its last tab goes.
… actions real Addresses @bvolpato's review. All three findings reproduce; two of them were silent successes, which is the worst failure mode this bridge has. 1. Explicit pane ids used a global lookup ----------------------------------------- `find_pane_widget_by_id` resolves against a process-wide registry keyed only by pane id. A request that *named* a workspace therefore reached straight past it: focus-pane --workspace B --pane 1 -> OK # pane 1 lives in workspace A It focused a pane in a different workspace than the one addressed, and reported success. `surface.create --pane <id>` had the same hole. Added `find_pane_widget_in_root`, which walks one workspace's tree and cannot cross a boundary, and routed both socket paths through it. The global lookup stays for the GUI's own paths -- drag-and-drop *is* allowed to move a pane between workspaces, and there it is the point -- but it is now documented as unsafe for socket callers. Miss resolves to `pane not found in this workspace` rather than a lie. (The reviewer also expected cross-workspace *creation* via `pane.create`; that one does not reproduce -- it is already rejected with `not_found`.) 2. `focus-pane` / `focus-surface` returned OK without focusing anything ----------------------------------------------------------------------- Focus cannot land on a workspace that is not on screen -- the pane is not displayed. Both verbs reported `ok: true` and changed nothing: selected = B; focus-pane --workspace A --pane 1 -> OK # B still selected Bring the workspace forward first, then focus. The reply now carries `selected_workspace: true` when it had to, so a caller is not left wondering why their sidebar moved. 3. Socket rename/pin bypassed the UI and never persisted -------------------------------------------------------- - `set_tab_pinned_in_pane` flipped the flag and never called `apply_pin_visuals`, so `tab-action --action pin` succeeded while the tab still looked unpinned. It now routes through the same visual update the GUI uses. - `TabAction` never called `request_session_save`, so a rename or pin made over the socket vanished on the next restart. It now saves. - An empty rename cleared `custom_name` without repainting the label, leaving the stale custom title on screen -- `on_title_changed` deliberately refuses to touch the label while a custom name is set, so nothing else would fix it. It now repaints with the tab kind's default title, and the process re-emits its real title on the next prompt redraw. That last path was in fact unreachable from the socket, for a reason worth fixing on its own: `optional_string` discards empty strings, so an explicit `--title ""` was indistinguishable from no title at all and was rejected as "rename requires title". An empty title is a *real request* -- it clears the override. The absent case and the empty case are now distinguished, and the error text says so. Verified live on Wayland with two workspaces: focus-pane --workspace B --pane 1 -> not_found: pane not found in this workspace focus-pane --workspace A --pane 1 -> OK, and A is now the selected workspace tab-action rename/pin -> persists to session.json, survives a restart tab-action rename --title "" -> clears the custom name; label falls back tab-action rename (no title) -> still an error, now with a hint
f91918b to
55935e8
Compare
|
Thanks — all three reproduce, and two of them were silent successes, which is the worst failure mode this bridge has. Fixed in 55935e8 (branch rebased onto the #114 fix). 1. Explicit pane ids used a global lookup ✅ fixed
It focused a pane in a different workspace than the one addressed and reported success. Added One correction: the cross-workspace creation case doesn't reproduce — 2.
|
Phase 2 of
docs/cmux-parity-plan.md("make the bridge a full proxy") for the surface/tab layer.The problem
Three of these methods were already advertised in
limux --helpand implemented in the CLI — but the GTK bridge answered-32601, so they looked supported and were not:That's the worst failure mode for an agent driving limux — the verb is discoverable, documented, and dead.
Separately, there was no way to close a pane at all.
close-workspacewas the only teardown verb, so an orchestrator could not reclaim a single worker without destroying the whole workspace.The fix
The GUI already implements all of this —
close_tab_in_pane,activate_tab_in_pane,add_terminal_tab_to_pane,add_browser_tab_to_pane_with_uri,focus_active_tab_in_pane. It just wasn't reachable from the socket. This wires it up rather than reimplementing it.Newly routed by the bridge:
surface.createnew-surfacesurface.closeclose-surfacesurface.focusfocus-surfacepane.focusfocus-panetab.actiontab-actionrename|pin|unpin|select|closenew-surfacenow forwards--pane,--typeand--urlinstead of sending an empty params object.rename-tabandtab-actionneeded no CLI change — they were always callingtab.action; it simply answers now.pane.rsgains three primitives in the existing helper style:rename_tab_in_pane,set_tab_pinned_in_pane, andlocate_surface_in_root(resolve a surface hint to its owning pane + tab).I deliberately did not take the tempting shortcut of falling the bridge through to
limux_core::Dispatcher: that dispatcher owns its own in-memoryControlState, so unknown methods would return a phantom model rather than live GTK state. Your plan says the same ("seeded with live GTK state"), so these are realControlCommandvariants.Verified live (Wayland/GTK4)
Gate
cargo fmt --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test -p limux-host-linux— 213 passed, including three new bridge tests (routing table,tab.actionrequires an action,surface.closedispatchesCloseSurface). These need no GTK display.cargo test --workspace— the one failure ishook_session_id_falls_back_to_transcript_stem, which also fails on an unmodified checkout (the known failure inCLAUDE.md). Unrelated.Note
add_browser_tab_to_pane_with_urialready exists in the GUI, sonew-surface --type browser --url …routes to it. I have not exercised the browser path end to end — thebrowser.*command family is still unrouted — so treat that flag as plumbing for the follow-up, not a claim that browser surfaces are done.