···11+# Canvas Multi-Select and Broadcast Input Design
22+33+## Goal
44+55+Let Canvas select multiple cards and send the same user input to all selected cards, with a strong emphasis on:
66+77+- natural multi-card selection on macOS (`Cmd+Click`)
88+- direct typing into Canvas without a separate batch-input textbox
99+- correct non-English input behavior
1010+- preserving current single-card interaction when multi-select is not active
1111+1212+This design targets the two main user scenarios discussed:
1313+1414+1. Open multiple cards backed by different agents and send the same prompt to compare results.
1515+2. Operate multiple remote SSH sessions and apply the same command/configuration to all of them.
1616+1717+---
1818+1919+## Non-Goals
2020+2121+This design does **not** try to make multiple terminals behave like a perfectly synchronized remote desktop.
2222+2323+Out of scope for v1:
2424+2525+- broadcasting mouse interactions to multiple cards
2626+- broadcasting search UI, text selection, or context menus
2727+- mirroring IME candidate windows/preedit UI to follower cards
2828+- guaranteeing perfect behavior for all full-screen TUIs (`vim`, `fzf`, `less`, `top`, etc.)
2929+- changing sidebar multi-selection or worktree detail selection behavior outside Canvas
3030+3131+---
3232+3333+## Current Architecture Summary
3434+3535+Canvas today is fundamentally a **single-focus** experience:
3636+3737+- `CanvasView` stores a single `focusedTabID`.
3838+- `CanvasCardView` only allows terminal hit testing when the card is focused.
3939+- Canvas exit behavior uses the focused canvas card to decide which worktree/tab to return to.
4040+- Terminal command routing is mostly **worktree-scoped**, while Canvas cards are effectively **tab-scoped**.
4141+4242+Relevant current implementation points:
4343+4444+- `supacode/Features/Canvas/Views/CanvasView.swift`
4545+- `supacode/Features/Canvas/Views/CanvasCardView.swift`
4646+- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`
4747+- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`
4848+- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`
4949+- `supacode/App/CommandKeyObserver.swift`
5050+5151+Important constraints from current code:
5252+5353+1. A card maps to a **tab**, not only a worktree.
5454+2. Input routing for active terminals depends on the focused `GhosttySurfaceView`.
5555+3. `GhosttySurfaceView` already supports AppKit IME (`NSTextInputClient`) and distinguishes:
5656+ - marked/preedit text (`setMarkedText` / `syncPreedit`)
5757+ - committed text (`insertText`)
5858+4. `CommandKeyObserver` already exists app-wide and can be reused to drive `Cmd`-based selection affordances.
5959+6060+---
6161+6262+## User Experience Design
6363+6464+## High-Level Model
6565+6666+Canvas supports:
6767+6868+- **primary focus**: the card that owns the real first responder and drives local input/IME
6969+- **multi-selection**: zero, one, or many selected cards
7070+- **selection mode**: a temporary click-interpretation mode entered by `Cmd+Click`
7171+7272+The key distinction is:
7373+7474+- **focus** decides where real AppKit/Ghostty input originates
7575+- **selection** decides which cards receive mirrored input
7676+7777+These are related but not identical.
7878+7979+---
8080+8181+## Selection Rules
8282+8383+### Entering selection mode
8484+8585+- `Cmd+Click` on any unselected card region enters selection mode.
8686+- The clicked card is added to selection.
8787+- The clicked card becomes the **primary selected card**.
8888+8989+"Any card region" includes the terminal content area, not only the title bar.
9090+9191+### While selection mode is active
9292+9393+- `Cmd+Click` on an unselected card adds it to selection.
9494+- `Cmd+Click` on a selected card removes it from selection.
9595+- If removal leaves one selected card, Canvas may stay visually selected but effectively returns to single-card behavior.
9696+- Clicking empty canvas clears selection and exits selection mode.
9797+9898+### Select all
9999+100100+- `Cmd+Opt+A` selects all visible cards for broadcast.
101101+- A toolbar button provides the same action with tooltip showing the hotkey.
102102+- If a primary card already exists, it is preserved; otherwise the last visible card becomes primary.
103103+104104+### While broadcasting (multiple cards selected, mode idle)
105105+106106+When multiple cards are selected and the user has begun typing (mode transitions from `.selecting` to `.idle`), the following behaviors apply:
107107+108108+- **Non-Cmd click on a follower card**: promotes it to primary without clearing multi-selection.
109109+- **Non-Cmd click on the primary card**: passes through to the terminal (shield is not shown on primary during broadcasting).
110110+- **Non-Cmd click on an unselected card**: clears multi-selection and focuses that single card.
111111+- **`Cmd+Click`**: toggles selection as usual.
112112+- **`Escape`**: clears all selection and exits broadcast mode.
113113+114114+### Leaving selection mode
115115+116116+The mode should be intentionally short-lived and should end on the first normal interaction.
117117+118118+- Any **non-Command keyboard input** when multiple cards are selected:
119119+ - exits the pure selection state
120120+ - immediately becomes a broadcast-input interaction
121121+- Clicking empty canvas:
122122+ - clears all selected cards
123123+ - clears primary focus in Canvas (0-selection is allowed)
124124+125125+This keeps selection lightweight and avoids sticky modifier-heavy behavior.
126126+127127+---
128128+129129+## Focus and Primary Card Semantics
130130+131131+When multiple cards are selected, exactly one selected card is still the **primary** card.
132132+133133+The primary card is responsible for:
134134+135135+- owning the real first responder
136136+- owning the visible IME composition/preedit state
137137+- serving as the source of mirrored input
138138+- deciding the worktree/tab used when exiting Canvas back to the normal terminal view
139139+140140+Selection without a primary card is invalid.
141141+142142+If the primary card is removed from selection:
143143+144144+- pick the most recently added remaining selected card as the new primary, or
145145+- if that history is unavailable, pick a deterministic fallback (e.g. the last card toggled on)
146146+147147+---
148148+149149+## Visual Design
150150+151151+### Selected card styling
152152+153153+Cards have two visual states:
154154+155155+- **primary focused/selected** card: 2pt accent-colored focus ring
156156+- **follower selected** cards: 1.5pt accent ring at 65% opacity + subtle background tint
157157+158158+### Broadcast hint
159159+160160+When more than one card is selected, a capsule badge appears in the bottom-right toolbar:
161161+162162+- `Broadcasting to N cards`
163163+164164+This is informational only, not a dedicated text entry field.
165165+166166+A separate textbox is intentionally rejected because it makes the interaction feel unlike a terminal.
167167+168168+### Toolbar
169169+170170+The canvas toolbar (bottom-right) contains:
171171+172172+- **Select All** button (`checkmark.rectangle.stack` icon) — tooltip: "Select all cards for broadcast (⌘⌥A)"
173173+- **Arrange** button — preserves card sizes
174174+- **Organize** button — uniform grid layout
175175+176176+---
177177+178178+## Input Behavior Design
179179+180180+## Core Principle
181181+182182+When multiple cards are selected, the user still types **once** into the primary card.
183183+Canvas mirrors that input to follower cards.
184184+185185+This should feel like:
186186+187187+- one real terminal under the cursor
188188+- N-1 follower terminals receiving mirrored input
189189+190190+---
191191+192192+## IME / Non-English Input Behavior
193193+194194+This is the most important rule:
195195+196196+> Followers must receive committed characters/words, not the phonetic keystrokes used to compose them.
197197+198198+Examples:
199199+200200+- Chinese Pinyin input should mirror `你好`, not `nihao`
201201+- Japanese input should mirror committed kana/kanji text, not unfinished romaji sequences
202202+203203+### IME behavior in v1
204204+205205+#### Primary card
206206+207207+The primary card handles the full native IME lifecycle as it does today:
208208+209209+- marked text / preedit
210210+- candidate window
211211+- commit
212212+- cancel
213213+214214+#### Follower cards
215215+216216+Follower cards do **not** render IME preedit/candidate UI.
217217+They receive only the final committed text.
218218+219219+That means:
220220+221221+- while the user is composing, followers may show no change yet
222222+- once composition commits, followers receive the committed string immediately
223223+224224+This is the intended design, not a degradation.
225225+It is the safest way to guarantee that non-English input remains semantically correct.
226226+227227+---
228228+229229+## Broadcast Categories
230230+231231+Input fan-out is split into two classes.
232232+233233+### 1. Committed text broadcast
234234+235235+Used for:
236236+237237+- English text input that arrives as text
238238+- committed IME text
239239+- pasted text (Cmd+V: after Ghostty handles the paste binding in `performKeyEquivalent`, reads `NSPasteboard.general` string and fires `onCommittedText`)
240240+241241+Behavior:
242242+243243+- take the committed string from the primary card
244244+- insert the same committed string into each follower card
245245+246246+### 2. Normalized special-key broadcast
247247+248248+Used for:
249249+250250+- `Enter`
251251+- `Backspace` / `Delete`
252252+- arrow keys (`↑ ↓ ← →`)
253253+- `Tab`
254254+- `Escape`
255255+- common shell control keys (for example `Ctrl-C`, `Ctrl-D`, `Ctrl-L`)
256256+- `Cmd+Backspace` (delete line)
257257+- `Cmd+Arrow` keys (line/word navigation)
258258+259259+Behavior:
260260+261261+- normalize the originating primary-card key event into a small mirror-safe model
262262+- replay that normalized input on followers
263263+264264+### Whitelisted Cmd combinations
265265+266266+A static whitelist (`commandAllowedKeyCodes`) controls which Cmd+key combinations pass through. Currently allowed:
267267+268268+- `Cmd+Backspace` (keyCode 51)
269269+- `Cmd+Arrow Left/Right/Down/Up` (keyCodes 123–126)
270270+271271+All other Cmd combinations are filtered out.
272272+273273+### Explicitly excluded from broadcast
274274+275275+Do not broadcast:
276276+277277+- `Cmd` shortcuts not in the whitelist (e.g. `Cmd+C`, `Cmd+W`, `Cmd+Q`)
278278+- menu shortcuts
279279+- window/app commands
280280+- mouse events
281281+- IME marked/preedit updates
282282+283283+This keeps the feature aligned with terminal input rather than app control.
284284+285285+---
286286+287287+## Implementation Design
288288+289289+## 1. Canvas Selection State
290290+291291+Selection state lives in `CanvasView` as `@State private var selectionState = CanvasSelectionState()`.
292292+293293+`CanvasSelectionState` is a pure value type with:
294294+295295+```swift
296296+struct CanvasSelectionState: Equatable {
297297+ enum Mode: Equatable { case idle, selecting }
298298+299299+ private(set) var mode: Mode
300300+ private(set) var selectedTabIDs: Set<TerminalTabID>
301301+ private(set) var primaryTabID: TerminalTabID?
302302+ private(set) var selectionOrder: [TerminalTabID]
303303+304304+ var isSelecting: Bool // mode == .selecting
305305+ var isBroadcasting: Bool // selectedTabIDs.count > 1
306306+307307+ mutating func focusSingle(_ tabID: TerminalTabID)
308308+ mutating func toggleSelection(_ tabID: TerminalTabID)
309309+ mutating func setPrimary(_ tabID: TerminalTabID)
310310+ mutating func selectAll(_ tabIDs: [TerminalTabID])
311311+ mutating func beginBroadcastInteractionIfNeeded()
312312+ mutating func clear()
313313+ mutating func prune(to visibleTabIDs: Set<TerminalTabID>)
314314+}
315315+```
316316+317317+### Why keep this in `CanvasView` for v1
318318+319319+The behavior is Canvas-local and highly UI-driven.
320320+There is no strong need to move it into TCA reducer state yet.
321321+322322+The pure `CanvasSelectionState` struct makes the transition logic fully testable without SwiftUI.
323323+324324+---
325325+326326+## 2. Cmd+Click Anywhere on a Card
327327+328328+### Problem
329329+330330+Today the focused terminal content receives hit testing, which means the terminal area would normally steal clicks.
331331+A title-bar-only approach is not acceptable.
332332+333333+### Implemented solution: selection shield overlay + per-card visibility
334334+335335+When either of the following is true:
336336+337337+- `CommandKeyObserver.isPressed == true`, or
338338+- `selectionMode == .selecting`
339339+340340+Canvas places a transparent hit-testing layer over every visible card.
341341+342342+Additionally, during **broadcasting** (multiple cards selected, mode idle):
343343+344344+- follower cards keep the shield (intercept clicks for `setPrimary` behavior)
345345+- the primary card does **not** show the shield (allows terminal click-through)
346346+347347+This is computed per-card via `showsSelectionShield(for: TerminalTabID) -> Bool`.
348348+349349+### Cmd key detection
350350+351351+**Important**: `CommandKeyObserver` has a 300ms hold delay (designed for shortcut hints UI). This means the shield may not render in time for fast Cmd+Click.
352352+353353+To handle this, `onTap` and `handleSelectionShieldTap` read `NSEvent.modifierFlags.contains(.command)` directly from hardware state, bypassing the observer's delay. The observer is still used for shield rendering (a brief visual delay is acceptable).
354354+355355+---
356356+357357+## 3. Make Broadcast Tab-Scoped, Not Worktree-Scoped
358358+359359+Current terminal commands are mostly scoped by `Worktree`.
360360+Canvas cards are scoped by `TerminalTabID`.
361361+362362+### Tab-targeted helpers on `WorktreeTerminalState`
363363+364364+```swift
365365+func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool
366366+func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool
367367+```
368368+369369+### Lookup and broadcast helpers on `WorktreeTerminalManager`
370370+371371+```swift
372372+func stateContaining(tabId: TerminalTabID) -> WorktreeTerminalState?
373373+func broadcastCommittedText(_ text: String, from: TerminalTabID, to: Set<TerminalTabID>) -> Int
374374+func broadcastMirroredKey(_ key: MirroredTerminalKey, from: TerminalTabID, to: Set<TerminalTabID>) -> Int
375375+```
376376+377377+Broadcast failures are logged via `SupaLogger` for debugging.
378378+379379+---
380380+381381+## 4. Broadcast Hooks on `GhosttySurfaceView`
382382+383383+### Callbacks
384384+385385+```swift
386386+var onCommittedText: ((String) -> Void)?
387387+var onMirroredKey: ((MirroredTerminalKey) -> Void)?
388388+```
389389+390390+- `onCommittedText` fires in `insertText()` after text is committed, and in `performKeyEquivalent` after Ghostty handles a Cmd+V binding.
391391+- `onMirroredKey` fires in `keyDown()` when the event normalizes to a `MirroredTerminalKey`.
392392+393393+Note: A separate `onPasteText` callback was considered but rejected. Paste is handled by firing `onCommittedText` from `performKeyEquivalent` after Ghostty processes the Cmd+V binding. The `paste(_ sender:)` IBAction is not used because Cmd+V is intercepted by Ghostty's binding system before reaching the responder chain's paste action.
394394+395395+### `MirroredTerminalKey`
396396+397397+```swift
398398+struct MirroredTerminalKey: Equatable, Sendable {
399399+ enum Kind: Equatable, Sendable {
400400+ case enter, backspace, deleteForward
401401+ case arrowUp, arrowDown, arrowLeft, arrowRight
402402+ case tab, escape, controlCharacter
403403+ }
404404+405405+ let kind: Kind
406406+ let keyCode: UInt16
407407+ let characters: String
408408+ let charactersIgnoringModifiers: String
409409+ let modifierFlagsRawValue: UInt // raw UInt for Sendable conformance
410410+ let isRepeat: Bool
411411+412412+ var modifiers: NSEvent.ModifierFlags { ... } // computed from raw value
413413+}
414414+```
415415+416416+The struct stores `modifierFlagsRawValue` (raw `UInt`) instead of `NSEvent.ModifierFlags` to satisfy `Sendable` conformance, since callbacks cross async boundaries via `Task { @MainActor in }`.
417417+418418+A static whitelist (`commandAllowedKeyCodes`) allows specific Cmd+key combinations through; all other Cmd events return `nil` from the initializer.
419419+420420+---
421421+422422+## 5. Safe Follower Insertion APIs
423423+424424+```swift
425425+func insertCommittedTextForBroadcast(_ text: String)
426426+func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool
427427+```
428428+429429+- `insertCommittedTextForBroadcast(_:)` writes committed UTF-8 text directly to the surface via `ghostty_surface_text`.
430430+- `applyMirroredKeyForBroadcast(_:)` replays a normalized key on the target surface via `keyDown`/`keyUp` without making it the app first responder.
431431+432432+Follower cards **never** steal first responder during broadcast. The primary card remains the real focused AppKit responder.
433433+434434+---
435435+436436+## 6. Event Flow
437437+438438+### A. Multi-select click flow
439439+440440+1. User holds `Cmd`.
441441+2. Canvas enables selection shield overlays (may have up to 300ms delay from observer).
442442+3. User clicks any card region.
443443+4. `onTap` or `onSelectionTap` fires; both check `NSEvent.modifierFlags.contains(.command)` for reliable detection.
444444+5. Canvas toggles that `tabID` in `selectedTabIDs`.
445445+6. Canvas updates `primaryTabID` if needed.
446446+7. Ghostty does not consume that click.
447447+448448+### B. Click during broadcasting (multiple cards selected)
449449+450450+1. User clicks a follower card without `Cmd`.
451451+2. Follower card has selection shield (per-card shield logic).
452452+3. `handleSelectionShieldTap` detects `isBroadcasting` and the card is selected.
453453+4. Canvas calls `setPrimary` — promotes the clicked card to primary without clearing multi-selection.
454454+5. If the user clicks the **primary** card (no shield), the click passes through to the terminal.
455455+456456+### C. IME composition on primary card
457457+458458+1. User types with IME on the primary card.
459459+2. Primary card receives `setMarkedText(...)` and updates preedit locally.
460460+3. No follower update happens yet.
461461+4. User commits a candidate.
462462+5. Primary card receives `insertText(...)` with committed text.
463463+6. `onCommittedText` callback fires, broadcasting committed string to followers.
464464+465465+### D. Paste broadcast (Cmd+V)
466466+467467+1. User presses Cmd+V on the primary card.
468468+2. `performKeyEquivalent` detects Cmd+V has a Ghostty binding, calls `keyDown(with: event)`.
469469+3. Ghostty internally performs `paste_from_clipboard` and writes clipboard content to the primary surface.
470470+4. After `keyDown` returns, `performKeyEquivalent` reads `NSPasteboard.general.string(forType: .string)` and fires `onCommittedText`.
471471+4. Broadcast callbacks mirror the pasted text to all follower cards.
472472+473473+### E. Enter key broadcast
474474+475475+1. Primary card receives Enter.
476476+2. Primary card submits normally.
477477+3. `onMirroredKey` emits `.enter` mirrored key.
478478+4. Followers receive `.enter` via `applyMirroredKeyForBroadcast`.
479479+480480+---
481481+482482+## 7. Interaction With Existing Canvas Exit Behavior
483483+484484+Current Canvas exit uses the focused canvas card to decide which worktree/tab to restore.
485485+That continues to use the **primary selected card** via `canvasFocusedWorktreeID`.
486486+487487+Rules:
488488+489489+- if multiple cards are selected, exiting Canvas returns to the primary card's owning worktree/tab
490490+- if selection was cleared and no primary remains, Canvas exits to the prior normal fallback behavior
491491+- clicking empty canvas may leave Canvas with 0 selection and 0 focused card; this is acceptable
492492+493493+---
494494+495495+## Alternatives Considered
496496+497497+## Rejected: title-bar-only multi-select
498498+499499+Rejected because users must be able to select from the terminal area too.
500500+In Canvas, the card is the object, not only its title bar.
501501+502502+## Rejected: dedicated batch-input textbox
503503+504504+Rejected because it makes terminal broadcast feel indirect and unlike the rest of Prowl.
505505+Direct typing is the intended interaction.
506506+507507+## Rejected: full raw-event mirroring for IME
508508+509509+Rejected because it would risk propagating phonetic composition keys (`nihao`, romaji, etc.) instead of committed text.
510510+Correct multilingual output is more important than perfect preedit mirroring.
511511+512512+## Rejected: separate `onPasteText` callback
513513+514514+Rejected because paste can be handled by firing `onCommittedText` from `paste()` after Ghostty completes the paste action. This avoids an extra callback and reuses the existing broadcast plumbing.
515515+516516+---
517517+518518+## Suggested File-Level Changes
519519+520520+### Primary feature files
521521+522522+- `supacode/Features/Canvas/Views/CanvasView.swift`
523523+ - selection state (`CanvasSelectionState`)
524524+ - selection-mode transitions via `mutateSelection`
525525+ - broadcast callback setup/teardown via `syncBroadcastCallbacks`
526526+ - broadcast status UI in toolbar
527527+ - selection shield (per-card via `showsSelectionShield(for:)`)
528528+ - `Cmd+Opt+A` select all, `Escape` to clear
529529+ - `NSEvent.modifierFlags` for reliable Cmd detection in tap handlers
530530+531531+- `supacode/Features/Canvas/Views/CanvasCardView.swift`
532532+ - selected/follower styling (border color, line width, background tint)
533533+ - selection shield overlay (`onSelectionTap`)
534534+ - normal terminal hit testing preserved outside selection/broadcast mode
535535+536536+### Selection model
537537+538538+- `supacode/Features/Canvas/Models/CanvasSelectionState.swift`
539539+ - pure value type for selection transitions
540540+541541+### Terminal model / manager
542542+543543+- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`
544544+ - tab-scoped `insertCommittedText` and `applyMirroredKey`
545545+546546+- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`
547547+ - `stateContaining(tabId:)` lookup
548548+ - `broadcastCommittedText` / `broadcastMirroredKey` fan-out with debug logging
549549+550550+### Ghostty bridge
551551+552552+- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`
553553+ - `onCommittedText` / `onMirroredKey` callbacks
554554+ - `insertCommittedTextForBroadcast` / `applyMirroredKeyForBroadcast` follower APIs
555555+ - paste broadcast via `onCommittedText` in `paste()`
556556+ - IME preedit stays primary-only
557557+558558+- `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift`
559559+ - normalized key model with `Sendable` conformance
560560+ - `commandAllowedKeyCodes` whitelist for Cmd+Backspace/Arrow
561561+562562+---
563563+564564+## Verification Strategy
565565+566566+## Automated
567567+568568+### Pure selection-state tests (`CanvasSelectionStateTests`)
569569+570570+- `focusSingle` sets primary and clears selection mode
571571+- `toggleSelection` enters selection mode and appends order
572572+- toggling selected primary promotes previous selection
573573+- toggling last selected card clears state
574574+- `beginBroadcastInteraction` leaves selection set but exits selection mode
575575+- `setPrimary` promotes follower without clearing selection
576576+- `setPrimary` ignores unselected tab
577577+- `selectAll` selects every tab and keeps existing primary
578578+- `selectAll` from empty picks last tab
579579+- `prune` drops missing tabs and preserves newest visible primary
580580+581581+### Mirrored key tests (`MirroredTerminalKeyTests`)
582582+583583+- Enter event normalizes correctly
584584+- Command-modified events are filtered out (e.g. Cmd+C returns nil)
585585+- Cmd+Backspace is allowed through whitelist
586586+- Cmd+Arrow is allowed through whitelist
587587+- Control character event normalizes correctly
588588+- Plain text event does not normalize as mirrored key
589589+590590+## Manual
591591+592592+### Shell / SSH
593593+594594+- select 2+ SSH cards
595595+- type a command like `pwd`
596596+- verify all cards receive the same text
597597+- press Enter
598598+- verify all cards execute once
599599+- test `Ctrl-C`
600600+- test `Cmd+Backspace` (delete line)
601601+- test `Cmd+V` paste
602602+603603+### Agent prompt comparison
604604+605605+- select 2+ agent cards
606606+- type the same prompt
607607+- verify all cards receive the same committed prompt text
608608+609609+### IME
610610+611611+- use Chinese Pinyin
612612+- compose text in primary card
613613+- verify followers do not show phonetic intermediate text
614614+- commit the candidate
615615+- verify followers receive committed Chinese text
616616+617617+- repeat with Japanese input
618618+619619+### Selection UX
620620+621621+- `Cmd+Click` terminal area of focused and unfocused cards
622622+- ordinary click exits selection mode correctly
623623+- blank-canvas click clears selection
624624+- `Cmd+Opt+A` selects all cards
625625+- `Escape` clears broadcast selection
626626+- click follower during broadcasting promotes to primary
627627+- click primary during broadcasting passes through to terminal
628628+- exit Canvas returns to the primary card's worktree/tab
629629+630630+---
631631+632632+## Risks
633633+634634+1. **Ghostty/AppKit event ordering**
635635+ - follower replay must not interfere with the primary first responder
636636+637637+2. **IME edge cases**
638638+ - candidate confirmation behavior may differ by input method
639639+ - design intentionally limits follower behavior to committed text
640640+641641+3. **Complex TUIs**
642642+ - some full-screen or mouse-driven apps may not behave intuitively under broadcast
643643+ - acceptable for v1
644644+645645+4. **Click/drag interaction overlap**
646646+ - card drag gestures and selection clicks must be thresholded cleanly
647647+648648+5. **CommandKeyObserver delay**
649649+ - 300ms hold delay means shield may not render for fast Cmd+Click
650650+ - mitigated by reading `NSEvent.modifierFlags` directly in tap handlers
651651+652652+---
653653+654654+## Recommended Delivery Shape
655655+656656+Implement this in slices:
657657+658658+### Slice 1
659659+- selection state model
660660+- Cmd+Click anywhere using selection shield
661661+- follower selected styling
662662+- clear/exit behavior
663663+664664+### Slice 2
665665+- tab-scoped terminal helpers
666666+- primary/follower broadcast plumbing
667667+- committed text broadcast
668668+- Enter/backspace/arrows/basic control keys
669669+670670+### Slice 3
671671+- IME hardening
672672+- paste behavior (Cmd+V broadcast)
673673+- Cmd+Backspace/Arrow whitelist
674674+- select all (Cmd+Opt+A)
675675+- Escape to clear broadcast
676676+- per-card shield during broadcasting
677677+- edge-case polish and manual verification
678678+679679+This keeps UX validation separate from lower-level Ghostty input fan-out.
680680+681681+---
682682+683683+## Final Recommendation
684684+685685+Proceed with a design that treats Canvas multi-select as:
686686+687687+- **card-level selection anywhere on the card**, not title-bar-only
688688+- **primary-card-driven live broadcast**, not a separate textbox
689689+- **IME commit-text mirroring**, not phonetic keystroke mirroring
690690+691691+That combination best matches the requested UX while staying implementable in the current Prowl/Ghostty architecture.
···11+# Canvas Multi-Select Broadcast Implementation Plan
22+33+**Goal:** Implement Canvas multi-card selection with direct broadcast input, including committed-text IME fan-out, while preserving current single-card behavior when multi-select is inactive.
44+55+**Scope:**
66+- In:
77+ - Canvas-local multi-selection state and transitions
88+ - Cmd+Click selection across full card area
99+ - Primary vs follower selected styling
1010+ - Broadcast of committed text plus a small set of normalized special keys
1111+ - Whitelisted Cmd+key broadcast (Cmd+Backspace, Cmd+Arrow)
1212+ - Cmd+V paste broadcast via pasteboard string
1313+ - Cmd+Opt+A select all, Escape to clear
1414+ - Per-card selection shield during broadcasting
1515+ - IME-safe follower behavior using committed text only
1616+ - Tests for selection state transitions and input normalization/filtering
1717+- Out:
1818+ - Mouse broadcast
1919+ - Full TUI parity for all applications
2020+ - Follower-side IME candidate/preedit UI
2121+2222+**Architecture:**
2323+- Keep selection state local to Canvas as `@State var selectionState = CanvasSelectionState()`.
2424+- Add a transparent selection shield so Cmd+Click works across the whole card, including terminal content. Shield visibility is per-card during broadcasting (follower cards keep shield, primary does not).
2525+- Keep one primary card as the real first responder; mirror input from it to follower cards.
2626+- Use `NSEvent.modifierFlags` for immediate Cmd detection in tap handlers (bypasses `CommandKeyObserver`'s 300ms hold delay).
2727+- Introduce `MirroredTerminalKey` (Sendable) for normalized key replay with a Cmd-key whitelist.
2828+- Treat IME specially: only committed text fans out; preedit stays primary-only.
2929+- Broadcast paste content by reading `NSPasteboard.general` string in `paste()` and firing `onCommittedText`.
3030+3131+**Acceptance / Verification:**
3232+- Cmd+Click anywhere on a card toggles selection.
3333+- Non-Cmd click exits selection mode and returns to single-card interaction.
3434+- Non-Cmd click on a follower during broadcasting promotes it to primary.
3535+- Non-Cmd click on the primary during broadcasting passes through to terminal.
3636+- Clicking blank canvas clears selection and focus.
3737+- Escape clears broadcast selection.
3838+- Cmd+Opt+A selects all visible cards.
3939+- Multiple selected cards receive mirrored committed text.
4040+- Cmd+V paste text is broadcast to followers.
4141+- Cmd+Backspace and Cmd+Arrow are broadcast to followers.
4242+- Followers receive committed Chinese/Japanese text, not phonetic intermediate input.
4343+- Build passes and targeted tests pass.
4444+4545+## Task 1: Add pure Canvas selection state machine ✅
4646+4747+**Files:**
4848+- Created: `supacode/Features/Canvas/Models/CanvasSelectionState.swift`
4949+- Created: `supacodeTests/CanvasSelectionStateTests.swift`
5050+5151+**Delivered:**
5252+- Pure `CanvasSelectionState` struct with `focusSingle`, `toggleSelection`, `setPrimary`, `selectAll`, `beginBroadcastInteractionIfNeeded`, `clear`, `prune`.
5353+- 10 tests covering all state transitions.
5454+5555+## Task 2: Integrate selection model into CanvasView ✅
5656+5757+**Files:**
5858+- Modified: `supacode/Features/Canvas/Views/CanvasView.swift`
5959+6060+**Delivered:**
6161+- Replaced `focusedTabID` with `selectionState: CanvasSelectionState`.
6262+- `mutateSelection` helper centralizes state mutation, pruning, focus sync, and callback sync.
6363+- z-order respects primary > selected > unselected.
6464+6565+## Task 3: Add selected/follower visuals and selection shield hooks ✅
6666+6767+**Files:**
6868+- Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift`
6969+7070+**Delivered:**
7171+- Primary: 2pt accent border. Follower: 1.5pt accent at 65% opacity + background tint.
7272+- `selectionShield` overlay intercepts clicks via `onSelectionTap`.
7373+- Resize handles hidden when shield is active.
7474+- Terminal hit testing: `allowsHitTesting(isFocused && !showsSelectionShield)`.
7575+7676+## Task 4: Wire Cmd+Click anywhere on card ✅
7777+7878+**Files:**
7979+- Modified: `supacode/Features/Canvas/Views/CanvasView.swift`
8080+- Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift`
8181+8282+**Delivered:**
8383+- `showsSelectionShield(for:)` is per-card: all cards during `Cmd`/selecting; only followers during broadcasting.
8484+- `onTap` checks `NSEvent.modifierFlags.contains(.command)` directly for reliable Cmd detection (bypasses 300ms observer delay).
8585+- `handleSelectionShieldTap` dispatches to `toggleSelection`, `setPrimary`, or `focusSingle` based on Cmd state and broadcasting state.
8686+- Blank-canvas click clears selection.
8787+8888+## Task 5: Add normalized mirrored-key model ✅
8989+9090+**Files:**
9191+- Created: `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift`
9292+- Created: `supacodeTests/MirroredTerminalKeyTests.swift`
9393+9494+**Delivered:**
9595+- `MirroredTerminalKey: Equatable, Sendable` with kinds: enter, backspace, deleteForward, arrows, tab, escape, controlCharacter.
9696+- Stores `modifierFlagsRawValue: UInt` for Sendable (computed `modifiers` property).
9797+- `commandAllowedKeyCodes` whitelist: Cmd+Backspace (51), Cmd+Arrow (123–126). All other Cmd combos rejected.
9898+- 6 tests covering normalization, Cmd filtering, whitelist, and plain-text rejection.
9999+100100+## Task 6: Add Ghostty broadcast hooks and safe follower APIs ✅
101101+102102+**Files:**
103103+- Modified: `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`
104104+105105+**Delivered:**
106106+- `onCommittedText` callback: fires in `insertText()` and in `paste()` (reads pasteboard string).
107107+- `onMirroredKey` callback: fires in `keyDown()` for normalized keys.
108108+- `insertCommittedTextForBroadcast(_:)`: writes UTF-8 text via `ghostty_surface_text`.
109109+- `applyMirroredKeyForBroadcast(_:)`: replays NSEvent via `keyDown`/`keyUp` without stealing responder.
110110+111111+## Task 7: Add tab-scoped terminal broadcast helpers ✅
112112+113113+**Files:**
114114+- Modified: `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`
115115+- Modified: `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`
116116+117117+**Delivered:**
118118+- `WorktreeTerminalState.insertCommittedText(_:in:)` and `applyMirroredKey(_:in:)`.
119119+- `WorktreeTerminalManager.stateContaining(tabId:)` lookup.
120120+- `broadcastCommittedText` / `broadcastMirroredKey` fan-out methods with `@discardableResult` return count.
121121+- Debug logging via `SupaLogger` for broadcast failures.
122122+123123+## Task 8: Connect primary-card input to follower broadcast ✅
124124+125125+**Files:**
126126+- Modified: `supacode/Features/Canvas/Views/CanvasView.swift`
127127+128128+**Delivered:**
129129+- `syncBroadcastCallbacks` sets `onCommittedText`/`onMirroredKey` on primary surface's leaves only when broadcasting.
130130+- `clearBroadcastCallbacks` nils out all callbacks on all surfaces.
131131+- Callbacks use explicit capture list with `beginBroadcast` closure for safe `selectionState` mutation.
132132+- Callbacks re-sync after split operations on primary card.
133133+- Callbacks sync on `onAppear`, `onChange(allCardKeys)`, `onChange(allTabIDs)`, `mutateSelection`, `pruneSelection`, `deactivateCanvas`.
134134+135135+## Task 9: Add Canvas keyboard shortcuts and toolbar ✅
136136+137137+**Files:**
138138+- Modified: `supacode/Features/Canvas/Views/CanvasView.swift`
139139+140140+**Delivered:**
141141+- `.onKeyPress(.escape)`: clears selection when broadcasting.
142142+- `.onKeyPress("a", phases: .down)` with `keyPress.modifiers == [.command, .shift]`: selects all cards.
143143+- Toolbar: select-all button + broadcasting badge + arrange + organize.
144144+145145+## Task 10: Polish and verification ✅
146146+147147+**Delivered:**
148148+- Fixed reversed canvas scroll direction (removed incorrect delta negation in `CanvasScrollContainerView`).
149149+- Fixed unsafe `selectionState` capture in broadcast callbacks.
150150+- Made `MirroredTerminalKey` Sendable via raw UInt storage.
151151+- Added Cmd+Backspace/Arrow whitelist.
152152+- Added Cmd+V paste broadcast.
153153+- All tests pass. Build passes. Lint passes.
154154+- Design and implementation plan docs updated to match final implementation.