Skip to content

macos,gtk: add broadcast support to sync across panes - #13745

Open
dave92082 wants to merge 15 commits into
ghostty-org:mainfrom
dave92082:feature/multi-pane-broadcast
Open

macos,gtk: add broadcast support to sync across panes#13745
dave92082 wants to merge 15 commits into
ghostty-org:mainfrom
dave92082:feature/multi-pane-broadcast

Conversation

@dave92082

@dave92082 dave92082 commented Aug 11, 2026

Copy link
Copy Markdown

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 a pane while you hold the modifier keys. This click puts the pane into a group or removes it from its group. If the pane that has focus is in a group, the clicked pane goes into the same group. If not, the
    click makes a new group. The new group gets the lowest free group number.
  • Use the broadcast-group-click-mods option to set the modifier keys. The default is super+shift on macOS. The default is ctrl+shift on GTK. Set the option to false to disable the click function.
  • The modifier keys must be an exact match. Thus the click cannot change the other click functions. These functions stay the same: shift+click, cmd+click, ctrl+click, and ctrl+alt+drag.
  • Use the toggle_broadcast_group:N key binding to put the pane that has focus into group N. N can be 1 to 16. Use the same key binding again to remove the pane from the group. The default key bindings are for
    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.
  • Use the clear_broadcast_groups action to remove all groups at one time. This action is also in the command palette.
  • Use the broadcast-group-colors option to set the border colors. This option operates the same as the palette option. Each N=color value changes one color. You do not give the full list. Example:
    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.

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.
@dave92082
dave92082 requested review from a team as code owners August 11, 2026 07:15

@bo2themax bo2themax left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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_group not join_broadcast_group to more accurately reflect what they are doing.
  • There should be a action clear_broadcast_groups that 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.
@dave92082

dave92082 commented Aug 11, 2026

Copy link
Copy Markdown
Author

@bo2themax:

  1. I updated the shortcuts for clicking in to the groups to be configurable following the same config override pattern used elsewhere for key+mouse interactions.
  2. With the changes requested by @jcollie to centralize the functionality it makes much less sense to split the work up between 2/3 PR's. If you think that is still needed, let me know and perhaps I can stage Core on one and UI's on a second

@jcollie:

  1. Great callout for moving the logic to core, I have re-worked it and the runtimes now share the Core implementation.
  2. Actions have been renamed as requested
  3. clear_broadcast_group has been added and is available in the command palette
  4. Borders colors are now configurable with a default of the same 10. If configured, up to 64 are allowed which would increase the number of allowable groups. Not sure anyone would have a need to go that high but would love to see a screenshot if they did :)

Other items I found and fixed while test driving it today for a while:

  • Pasted input was not being sent to all broadcast group members.
  • Non visible input characters (Home, End, etc.) where not being sent to all broadcast group members

…broadcast

# Conflicts:
#	macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
@dave92082
dave92082 requested review from bo2themax and jcollie August 11, 2026 21:57
    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).
@jcollie

jcollie commented Aug 12, 2026

Copy link
Copy Markdown
Member

Still working my way through this, but here's a couple of initial thoughts:

  • The BroadcastGroup seems over-engineered. Lots of O(n²) operations. I think that just adding broadcast_group: ?u8 = null to the surface would work better. Sending a broadcast to a group is just an O(n) operation then. Plus eliminates many allocations.
  • Keeping track of the maximum number of configured colors seems like unnecessary work. We should pick a maximum number of groups (16? 64 I think is overkill) and just stick with that. We'd have to pick distinct-enough default colors for all of them though.
  • We should re-use some of the machinery that is used for parsing palettes. That seems like a better way to configure the colors than what ColorList provides. This would also allow for overriding only specific colors rather than having to always specifying the whole list.
  • On GTK, the broadcast border should be controlled through a template binding (see how the bell border or the readonly overlay are controlled). Unfortunately GTK doesn't allow you to add/remove CSS classes through templates so that won't work.

(No comment on the Swift code as I'm not a macOS dev).

@dave92082

dave92082 commented Aug 12, 2026

Copy link
Copy Markdown
Author

@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
Comment thread src/apprt/gtk/class/application.zig Outdated
Comment thread src/apprt/action.zig Outdated
Comment thread src/App.zig Outdated
Comment thread src/App.zig Outdated
Comment thread macos/Sources/Ghostty/Ghostty.App.swift Outdated
Comment thread macos/Sources/Ghostty/Surface View/SurfaceView.swift Outdated
Comment thread macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift Outdated
@dave92082

dave92082 commented Aug 13, 2026

Copy link
Copy Markdown
Author

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.
@dave92082

Copy link
Copy Markdown
Author

@jcollie @pluiedev @bo2themax , all PR comments should be addressed. I have tested on both mac and fedora

@pluiedev pluiedev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. No more concerns on the GTK side.

@dave92082
dave92082 requested a review from bo2themax August 15, 2026 05:03
@dave92082

Copy link
Copy Markdown
Author

Updated PR description to reflect approved changes

@bo2themax

Copy link
Copy Markdown
Member

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OK, lotsa stuff here, but to summarize:

  1. 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 u4 to represent the group. That should let the compiler catch any problems.
  2. Action names should contain a verb so added toggle or sync in various places. Hopefully I caught all the necessary changes but the compiler may make a liar of me.
  3. Added a border-color to the base CSS class which fixes a minor visual glitch.
  4. 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.

Comment thread src/apprt/gtk/css/style.css
Comment thread src/apprt/gtk/class/application.zig Outdated
Comment thread src/apprt/gtk/class/application.zig Outdated
Comment thread src/apprt/gtk/class/surface.zig Outdated
Comment thread src/apprt/action.zig Outdated
Comment thread src/App.zig
Comment thread src/Surface.zig
Comment thread src/apprt/gtk/class/surface.zig Outdated
Comment thread src/apprt/gtk/class/surface.zig Outdated
Comment thread macos/Sources/Ghostty/Ghostty.App.swift Outdated
dave92082 and others added 2 commits August 17, 2026 09:54
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.
@dave92082

Copy link
Copy Markdown
Author

@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:

  • The compiler did indeed make a liar of you in a couple spots 🙂 the toggleNumberedBroadcastGroup suggestion referenced a number variable that no longer exists so I used group, and the GTK one had a pre-0.11 @intcast(u4, old) in it.
  • The GTK surface method ended up as syncBroadcastGroup since that's what the application.zig suggestion called it
  • I also renamed the ghostty_action_u union member to sync_broadcast_group to match the tag like the other actions, so the Swift side reads action.action.sync_broadcast_group rather than the broadcast_group in the suggestion.
  • Fun one: switching the action payload to u4 turned up a gap in cli/args.zig — the tagged-union parser didn't know how to parse u4 fields so keybind parsing wouldn't compile. Added it to the int list, which is the one change outside the files you touched.

@dave92082
dave92082 requested a review from a team as a code owner August 17, 2026 17:56
…broadcast

# Conflicts:
#	po/be.po
#	po/he.po
#	po/kk.po
#	po/ko_KR.po

@trag1c trag1c left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking on the new strings (we're at a string freeze for 1.4)

@dave92082

dave92082 commented Aug 17, 2026

Copy link
Copy Markdown
Author

Updated the translation files so all checks should pass. (Did not add translated strings)

@jcollie

jcollie commented Aug 17, 2026

Copy link
Copy Markdown
Member

I want to try this locally tonight before I approve, but this looks good!

@jcollie jcollie left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is looking good to me, ready to merge after 1.4 is released.

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.

5 participants