Skip to content

feat(control): surface/tab lifecycle over the control socket#115

Open
tairea wants to merge 4 commits into
am-will:mainfrom
tairea:feat/surface-tab-lifecycle
Open

feat(control): surface/tab lifecycle over the control socket#115
tairea wants to merge 4 commits into
am-will:mainfrom
tairea:feat/surface-tab-lifecycle

Conversation

@tairea

@tairea tairea commented Jul 12, 2026

Copy link
Copy Markdown

Stacked on #114. This branch contains that commit too; review #114 first, or read only the second commit here. Happy to rebase once #114 lands.

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 --help and implemented in the CLI — but the GTK bridge answered -32601, so they looked supported and were not:

$ limux new-surface --workspace $WS
-32601: unknown method: surface.create
$ limux rename-tab --workspace $WS --tab 3:terminal-0 worker-1
-32601: unknown method: tab.action
$ limux tab-action --tab 3:terminal-0 --action pin
-32601: unknown method: tab.action

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-workspace was 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:

method CLI behaviour
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-surface now forwards --pane, --type and --url instead of sending an empty params object. rename-tab and tab-action needed no CLI change — they were always calling tab.action; it simply answers now.

pane.rs gains three primitives in 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).

I deliberately did not take the tempting shortcut of falling the bridge through to limux_core::Dispatcher: that dispatcher owns its own in-memory ControlState, 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 real ControlCommand variants.

Verified live (Wayland/GTK4)

$ limux rename-tab --workspace $WS --tab 2:… worker-1
OK
$ limux --json list-panels --workspace $WS | jq -c '.surfaces|map(.title)'
["ian@…","worker-1","Terminal"]

$ limux new-surface --workspace $WS --pane pane:2      # second tab in pane 2
OK surface:2:69acc195-…

$ limux tab-action --workspace $WS --tab 2:… --action pin
OK
$ limux close-surface --workspace $WS --surface 2:…    # pinned → refused
-32009: cannot close a pinned surface
$ limux tab-action --workspace $WS --tab 2:… --action unpin
OK
$ limux close-surface --workspace $WS --surface 2:…
OK                                                     # pane collapses with its last tab

Gate

  • cargo fmt --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test -p limux-host-linux213 passed, including three new bridge tests (routing table, tab.action requires an action, surface.close dispatches CloseSurface). These need no GTK display.
  • cargo test --workspace — the one failure is hook_session_id_falls_back_to_transcript_stem, which also fails on an unmodified checkout (the known failure in CLAUDE.md). Unrelated.

Note

add_browser_tab_to_pane_with_uri already exists in the GUI, so new-surface --type browser --url … routes to it. I have not exercised the browser path end to end — the browser.* command family is still unrouted — so treat that flag as plumbing for the follow-up, not a claim that browser surfaces are done.

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 bvolpato left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-surface and focus-pane return 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)

tairea added 3 commits July 12, 2026 14:57
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
@tairea
tairea force-pushed the feat/surface-tab-lifecycle branch from f91918b to 55935e8 Compare July 13, 2026 02:07
@tairea

tairea commented Jul 13, 2026

Copy link
Copy Markdown
Author

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

find_pane_widget_by_id resolves against a process-wide registry keyed only by pane id, so a request that named a workspace reached straight past it:

$ limux focus-pane --workspace B --pane 1
OK                              # ...but 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 a single 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's the point — but it's now documented as unsafe for socket callers. A miss now returns not_found: pane not found in this workspace.

One correction: the cross-workspace creation case doesn't reproduce — pane.create already rejects it with not_found. It's specifically focus (and surface.create's pane hint) that leaked.

2. focus-pane / focus-surface returned OK without focusing ✅ fixed

Focus can't land on a workspace that isn't on screen — the pane isn't displayed. Both verbs reported ok: true and changed nothing:

selected = B;  focus-pane --workspace A --pane 1  ->  OK   # B is still selected

They now bring the workspace forward first, then focus, and the reply carries selected_workspace: true when it had to — so a caller isn't left wondering why their sidebar moved.

3. Socket rename/pin bypassed the UI and never persisted ✅ fixed

  • 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.
  • 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 have fixed it.

That last one had a second bug behind it, worth calling out: optional_string discards empty strings, so an explicit --title "" was indistinguishable from no title and got rejected as "rename requires title". An empty title is a real request — it clears the override and hands the tab back to its process-derived title. The absent and empty cases are now distinguished, and the error text says so.

Verified live

Two workspaces, Arch/Wayland, isolated XDG_DATA_HOME:

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

Your framing — root-scoped pane resolution plus shared GUI mutation paths — was the right shape, and I've taken it as the rule: every socket verb should go through the same code the GUI uses rather than a parallel path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants