macos,gtk: add broadcast support to sync across panes - #13745
Conversation
Add an iTerm2/tmux-style broadcast mode where keyboard input typed into one pane is replicated to every other pane in its group. Cmd+Shift+Click toggles a pane's group membership: if the focused pane is already in a group the clicked pane joins it, otherwise a new group is created. Multiple groups can exist at once (spanning tabs and windows), and each group's panes are outlined with a thin border in a distinct per-group color. The click is consumed before it reaches the terminal, and both modifiers are required so shift+click (extend selection), cmd+click (open link), and ctrl+click (context menu) keep their existing behavior. Input replication hooks the keyAction funnel plus the IME and sendText paths, so key events, composed text, and dictation all broadcast. Keys that would trigger a keybinding on a receiving surface are skipped so only terminal input is replicated, and modifier-only events are excluded because BaseTerminalController already fans those out per window. Group members are held weakly and windowless surfaces (closed terminals kept alive by undo) never receive input.
Add an iTerm2/tmux-style broadcast mode where keyboard input typed into one pane is replicated to every other pane in its group. Ctrl+Shift+Click toggles a pane's group membership: if the focused pane is already in a group the clicked pane joins it, otherwise a new group is created. Multiple groups can exist at once (spanning tabs and windows), and each group's panes are outlined with a thin border in a distinct per-group color. The click is consumed before it reaches the terminal, and exactly ctrl+shift is required so shift+click (extend selection), ctrl+click (open link), and ctrl+alt+drag (rectangle selection) keep their existing behavior. Super is excluded because window managers commonly grab super+click. Groups are managed by a new BroadcastGroups struct stored on the Application, tracking members by core surface ID so nothing dangles; surfaces leave their group on finalize. The border is a Revealer/Box overlay in the surface blueprint following the bell border pattern, placed above the unfocused-split dim layer, with a six-color palette in the stylesheet that cycles for additional groups. Input replication hooks the keyEvent funnel plus the IM commit path, so key events and IME/on-screen-keyboard text both broadcast. Keys that would trigger a keybinding on a receiving surface are skipped so only terminal input is replicated, and events are not replicated while composing so IME preedit state stays local.
Add a configurable keybinding action to join the focused terminal to a numbered broadcast input group: join_broadcast_group:N joins group N (1-10), creating it if needed. A terminal already in that group is toggled out, and a terminal in a different group is moved, so panes can be regrouped entirely from the keyboard. The defaults bind cmd+ctrl+1 through cmd+ctrl+9 to groups 1-9 and cmd+ctrl+0 to group 10 on macOS, where ctrl is used instead of shift because cmd+shift+3/4/5 are the system screen capture shortcuts. Everywhere else the defaults are ctrl+shift+<digit>, matching the ctrl+shift+click group toggle. Like the numeric goto_tab defaults, both the physical digit key and the unicode digit are registered so layouts with shifted digits (e.g. AZERTY) work. Groups are now identified by a fixed number that doubles as their palette index, and at most ten groups can exist at once, one per color. The click flow still assigns the lowest free number but refuses to create an eleventh group, which makes the color cycling unnecessary; both palettes grow from six colors to ten (adding red, blue, brown, and gray) so every group keeps a distinct border. The action flows through the standard plumbing: a new apprt action carries the one-based group number over the C API as GHOSTTY_ACTION_JOIN_BROADCAST_GROUP, GTK routes it to the surface widget, and macOS routes it to BroadcastGroups. Because it is a real binding it appears in +list-keybinds, can be rebound or used in key sequences, and is skipped by broadcast replication on receiving surfaces like any other bound key.
bo2themax
left a comment
There was a problem hiding this comment.
The results look good to me, but i would recommend separate changes to 2/3 prs(core, gtk, macOS). It's easier to review and more like to get merged incrementally.
I also don't like hard coding the shortcuts for enabling the broadcast selection. Maybe we should add a keybind for that.
jcollie
left a comment
There was a problem hiding this comment.
This is a great start, but I think that the control of the broadcast groups should be in the core app/surface and not in each individual runtime. That should reduce code duplication and ensure that the logic works the same across runtimes. It would also allow broadcast groups to be exposed to libghostty-vt users.
Minor nits:
- The actions should be called
toggle_broadcast_groupnotjoin_broadcast_groupto more accurately reflect what they are doing. - There should be a action
clear_broadcast_groupsthat would clear all broadcast groups at once so you don't have to remove them one by one. - The border colors should not be hardcoded, but confgurable.
One the above things are addressed I'm sure I'll have further feedback but that's more than enough to get started with.
Broadcast group state and behavior previously lived in each apprt
(BroadcastGroups.swift on macOS, apprt/gtk/BroadcastGroups.zig on GTK),
duplicating the membership rules and input replication logic and letting
the runtimes drift apart. Group control now lives entirely in the core
so every runtime behaves identically and libghostty embedders get the
feature through the existing binding action API.
Core changes:
- The group store moves to src/BroadcastGroups.zig, owned by App.
- Input replication happens inside Surface.keyCallback and
textCallback via new non-replicating *Local variants.
- Pastes replicate from the clipboard completion path, covering
paste_from_clipboard, paste_from_selection, and middle-click
primary paste.
The apprt interface shrinks to a one-way broadcast_group action that
reports membership changes for rendering, plus a
ghostty_surface_toggle_broadcast_group C API for the macOS click
gesture. The runtimes keep only gesture detection and border drawing.
The previously hardcoded behavior is now configurable:
- broadcast-group-click-mods sets the modifiers for toggling a
surface's membership by left click (default super+shift on macOS,
ctrl+shift elsewhere; `false` disables).
- broadcast-group-colors sets the group border colors and thereby
the maximum number of groups (up to 64).
The keybinding action is renamed to toggle_broadcast_group to reflect
what it does, and a new clear_broadcast_groups action (with a command palette entry)
dissolves all groups at once.
Other items I found and fixed while test driving it today for a while:
|
…broadcast # Conflicts: # macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
broadcastMaxGroups read rt_app.config to count the configured
broadcast-group-colors. That field only exists on the embedded apprt;
the GTK apprt's App is a thin wrapper around the Application GObject,
which keeps the config in its private state, so the core can't assume a
config field on the apprt.
The color count is now cached on the core App as broadcast_max_groups.
The core never retains a config and neither runtime pushes one into
the core at startup (App.updateConfig only runs on reloads).
|
Still working my way through this, but here's a couple of initial thoughts:
(No comment on the Swift code as I'm not a macOS dev). |
|
@jcollie Looking through the implementation and the codebase (long time user, first time contributor 😉), simplifying the BroadcastGroup as you suggest should make this overall change much smaller. I agree that 64 is overkill and that 16 should be more than enough for everyone. I've been daily driving this PR for a couple days now and haven't used more than 3. I'll work on incorporating the first three items tonight and will have to do some research for the GTK template binding though I'm not sure how fruitful that will be. |
Address review feedback on the broadcast group implementation:
- Group state is now just a `broadcast_group: ?u8` field on
Surface instead of a separate membership store. Broadcasting
becomes a single allocation-free pass over the surface list,
toggle/clear become field writes, and the surface-deletion
cleanup hook disappears since the state dies with the surface.
Iterating the surface list directly during replication is safe
because broadcast only ever delivers terminal input and terminal
writes are queued asynchronously.
- The maximum number of groups is now a fixed 16 instead of
being derived from the configured color count
- broadcast-group-colors is reworked to use the same machinery as
`palette`: a fixed 16-entry color table where repeatable
`N=color` values override individual entries instead of
requiring the full list. The C API exposes it as a fixed array
like ghostty_config_palette_s.
The default colors are reordered so that consecutive group numbers
are never visually similar: hue-adjacent pairs like red/pink and
blue/cyan are kept apart, and six new default colors are added to
reach 16
|
Not ignoring these comments today, lost power around 11am PST and it's still out. I'll get back to this tomorrow when it is (hopefully) back up |
…broadcast # Conflicts: # macos/Sources/Ghostty/Surface View/SurfaceView.swift
Address review feedback from the latest round:
- The broadcast-group-click-mods check moves from each runtime's
event handling into core mouseButtonCallback. The runtimes keep
only a thin routing exception in their focus-transfer logic so
that a broadcast-mod click on an unfocused surface still reaches
core mouse handling instead of being consumed as a focus click.
The press toggles membership and the matching release is
swallowed so the terminal never sees an unpaired release. The
ghostty_surface_toggle_broadcast_group C API is no longer needed
and is removed.
- The broadcast_group apprt action payload is now a plain ?u8
color with the C representation derived via cval(), matching
the codebase convention.
- lowestUnusedBroadcastGroup uses StaticBitSet. Note the
suggested findLastSet()+1 would compute one past the highest
used number, which never reuses freed low numbers and overflows
when the last group is in use; complement().findFirstSet()
keeps the lowest-unused semantics.
- The macOS broadcast_group handler hops to the main actor with a
Task instead of MainActor.assumeIsolated so a mis-threaded call
cannot crash.
- The iOS conditional compilation around the border overlay is
dropped now that iOS support is removed upstream.
AppKit makes the clicked view first responder while dispatching a left mouse down, before the view's mouseDown runs, and that focus change reaches the core synchronously (becomeFirstResponder -> focusDidChange -> ghostty_surface_set_focus). If a broadcast-mod click dispatched normally, the core toggle would therefore see the clicked surface as the focused surface and start a new group per click instead of joining the focused surface's group. Send the press to core mouse handling directly from the event monitor and consume the NSEvent so the automatic focus transfer never happens, then transfer focus explicitly after the toggle - the same toggle-then-focus ordering the runtime-side implementation used. Detection and the toggle itself remain in core; the monitor only reroutes the event. The real mouse-up still arrives through the normal path and the core swallows the unpaired release.
|
@jcollie @pluiedev @bo2themax , all PR comments should be addressed. I have tested on both mac and fedora |
pluiedev
left a comment
There was a problem hiding this comment.
Thanks. No more concerns on the GTK side.
|
Updated PR description to reflect approved changes |
I don't think you need to add to the hows in the description; descriptions are more of why and additional notes. This would help others to understand the changes when blaming. That big chunk of text seems overwhelming to me. |
jcollie
left a comment
There was a problem hiding this comment.
OK, lotsa stuff here, but to summarize:
- I really dislike the difference between 1-based group numbers and 0-based color indexes. Unifying everything on a 0-based index simplifies things, and lets us use a
u4to represent the group. That should let the compiler catch any problems. - Action names should contain a verb so added
toggleorsyncin various places. Hopefully I caught all the necessary changes but the compiler may make a liar of me. - Added a
border-colorto the base CSS class which fixes a minor visual glitch. - The GTK surface shouldn't retain the group state, it's easy enough to just remove every custom CSS class as removing a non-existent CSS class is not an error.
Co-authored-by: Jeffrey C. Ollie <jeff@ocjtech.us>
Apply review suggestions:
- Group numbers and color indexes are now the same zero-based u4
everywhere
- The apprt action is renamed from broadcast_group to sync_broadcast_group
so the name contains a verb, with the payload field renamed from color
to group
- The GTK base .broadcast-overlay class gets a transparent
border-color, fixing a flash of color when leaving a group
- The GTK surface no longer mirrors the group state to know which
CSS class to remove; it removes every possible broadcast-color-N
class since removing an absent class is not an error.
- BroadcastGroupColors.formatEntry reuses the Color formatter
instead of formatting hex manually.
|
@jcollie All suggestions applied thanks for the thorough pass! A few small things came up along the way that weren't in the suggestions themselves:
|
…broadcast # Conflicts: # po/be.po # po/he.po # po/kk.po # po/ko_KR.po
trag1c
left a comment
There was a problem hiding this comment.
Blocking on the new strings (we're at a string freeze for 1.4)
|
Updated the translation files so all checks should pass. (Did not add translated strings) |
|
I want to try this locally tonight before I approve, but this looks good! |
jcollie
left a comment
There was a problem hiding this comment.
This is looking good to me, ready to merge after 1.4 is released.
Summary
This change adds synchronized input across terminal panes. Users asked for this function in #3227, #11797, and #12832. You can put panes into groups. You can make a maximum of 16 groups. When you send input to one
pane in a group, all panes in that group get the same input. A colored border shows the panes in each group. The core code controls all groups. The macOS runtime and the GTK runtime use the same core code.
Programs that use libghostty can also use this function through the binding-action API.
Description
An earlier change (#10531) had one global switch. This change uses groups in place of one global switch. Each group has a different border color. A group can contain panes from different splits, tabs, and windows.
How to use the function
click makes a new group. The new group gets the lowest free group number.
groups 1 to 10. On macOS the default is cmd+ctrl+. On the other platforms the default is ctrl+shift+. The digit 0 is group 10. The key bindings apply to the physical digit keys and the unicode digit
keys. Thus keyboard layouts with shifted digits (for example, AZERTY) operate correctly.
broadcast-group-colors = 3=#ff0000. The 16 default colors are in a special sequence. Two adjacent group numbers do not get almost equal colors.
Screen.Recording.2026-08-10.at.6.53.14.PM.mov
AI disclosure
Claude Code with the Fable 5 model helped with this change. I made the design for the user interaction. I read each change. I did the manual tests on real hardware.
Vouched in #13742.