native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #53 from onevcat/feature/canvas-multiselect-broadcast

Add canvas multi-select broadcast input

authored by

Wei Wang and committed by
GitHub
9f746c65 db6f78c0

+1697 -66
+691
doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-design.md
··· 1 + # Canvas Multi-Select and Broadcast Input Design 2 + 3 + ## Goal 4 + 5 + Let Canvas select multiple cards and send the same user input to all selected cards, with a strong emphasis on: 6 + 7 + - natural multi-card selection on macOS (`Cmd+Click`) 8 + - direct typing into Canvas without a separate batch-input textbox 9 + - correct non-English input behavior 10 + - preserving current single-card interaction when multi-select is not active 11 + 12 + This design targets the two main user scenarios discussed: 13 + 14 + 1. Open multiple cards backed by different agents and send the same prompt to compare results. 15 + 2. Operate multiple remote SSH sessions and apply the same command/configuration to all of them. 16 + 17 + --- 18 + 19 + ## Non-Goals 20 + 21 + This design does **not** try to make multiple terminals behave like a perfectly synchronized remote desktop. 22 + 23 + Out of scope for v1: 24 + 25 + - broadcasting mouse interactions to multiple cards 26 + - broadcasting search UI, text selection, or context menus 27 + - mirroring IME candidate windows/preedit UI to follower cards 28 + - guaranteeing perfect behavior for all full-screen TUIs (`vim`, `fzf`, `less`, `top`, etc.) 29 + - changing sidebar multi-selection or worktree detail selection behavior outside Canvas 30 + 31 + --- 32 + 33 + ## Current Architecture Summary 34 + 35 + Canvas today is fundamentally a **single-focus** experience: 36 + 37 + - `CanvasView` stores a single `focusedTabID`. 38 + - `CanvasCardView` only allows terminal hit testing when the card is focused. 39 + - Canvas exit behavior uses the focused canvas card to decide which worktree/tab to return to. 40 + - Terminal command routing is mostly **worktree-scoped**, while Canvas cards are effectively **tab-scoped**. 41 + 42 + Relevant current implementation points: 43 + 44 + - `supacode/Features/Canvas/Views/CanvasView.swift` 45 + - `supacode/Features/Canvas/Views/CanvasCardView.swift` 46 + - `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` 47 + - `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` 48 + - `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` 49 + - `supacode/App/CommandKeyObserver.swift` 50 + 51 + Important constraints from current code: 52 + 53 + 1. A card maps to a **tab**, not only a worktree. 54 + 2. Input routing for active terminals depends on the focused `GhosttySurfaceView`. 55 + 3. `GhosttySurfaceView` already supports AppKit IME (`NSTextInputClient`) and distinguishes: 56 + - marked/preedit text (`setMarkedText` / `syncPreedit`) 57 + - committed text (`insertText`) 58 + 4. `CommandKeyObserver` already exists app-wide and can be reused to drive `Cmd`-based selection affordances. 59 + 60 + --- 61 + 62 + ## User Experience Design 63 + 64 + ## High-Level Model 65 + 66 + Canvas supports: 67 + 68 + - **primary focus**: the card that owns the real first responder and drives local input/IME 69 + - **multi-selection**: zero, one, or many selected cards 70 + - **selection mode**: a temporary click-interpretation mode entered by `Cmd+Click` 71 + 72 + The key distinction is: 73 + 74 + - **focus** decides where real AppKit/Ghostty input originates 75 + - **selection** decides which cards receive mirrored input 76 + 77 + These are related but not identical. 78 + 79 + --- 80 + 81 + ## Selection Rules 82 + 83 + ### Entering selection mode 84 + 85 + - `Cmd+Click` on any unselected card region enters selection mode. 86 + - The clicked card is added to selection. 87 + - The clicked card becomes the **primary selected card**. 88 + 89 + "Any card region" includes the terminal content area, not only the title bar. 90 + 91 + ### While selection mode is active 92 + 93 + - `Cmd+Click` on an unselected card adds it to selection. 94 + - `Cmd+Click` on a selected card removes it from selection. 95 + - If removal leaves one selected card, Canvas may stay visually selected but effectively returns to single-card behavior. 96 + - Clicking empty canvas clears selection and exits selection mode. 97 + 98 + ### Select all 99 + 100 + - `Cmd+Opt+A` selects all visible cards for broadcast. 101 + - A toolbar button provides the same action with tooltip showing the hotkey. 102 + - If a primary card already exists, it is preserved; otherwise the last visible card becomes primary. 103 + 104 + ### While broadcasting (multiple cards selected, mode idle) 105 + 106 + When multiple cards are selected and the user has begun typing (mode transitions from `.selecting` to `.idle`), the following behaviors apply: 107 + 108 + - **Non-Cmd click on a follower card**: promotes it to primary without clearing multi-selection. 109 + - **Non-Cmd click on the primary card**: passes through to the terminal (shield is not shown on primary during broadcasting). 110 + - **Non-Cmd click on an unselected card**: clears multi-selection and focuses that single card. 111 + - **`Cmd+Click`**: toggles selection as usual. 112 + - **`Escape`**: clears all selection and exits broadcast mode. 113 + 114 + ### Leaving selection mode 115 + 116 + The mode should be intentionally short-lived and should end on the first normal interaction. 117 + 118 + - Any **non-Command keyboard input** when multiple cards are selected: 119 + - exits the pure selection state 120 + - immediately becomes a broadcast-input interaction 121 + - Clicking empty canvas: 122 + - clears all selected cards 123 + - clears primary focus in Canvas (0-selection is allowed) 124 + 125 + This keeps selection lightweight and avoids sticky modifier-heavy behavior. 126 + 127 + --- 128 + 129 + ## Focus and Primary Card Semantics 130 + 131 + When multiple cards are selected, exactly one selected card is still the **primary** card. 132 + 133 + The primary card is responsible for: 134 + 135 + - owning the real first responder 136 + - owning the visible IME composition/preedit state 137 + - serving as the source of mirrored input 138 + - deciding the worktree/tab used when exiting Canvas back to the normal terminal view 139 + 140 + Selection without a primary card is invalid. 141 + 142 + If the primary card is removed from selection: 143 + 144 + - pick the most recently added remaining selected card as the new primary, or 145 + - if that history is unavailable, pick a deterministic fallback (e.g. the last card toggled on) 146 + 147 + --- 148 + 149 + ## Visual Design 150 + 151 + ### Selected card styling 152 + 153 + Cards have two visual states: 154 + 155 + - **primary focused/selected** card: 2pt accent-colored focus ring 156 + - **follower selected** cards: 1.5pt accent ring at 65% opacity + subtle background tint 157 + 158 + ### Broadcast hint 159 + 160 + When more than one card is selected, a capsule badge appears in the bottom-right toolbar: 161 + 162 + - `Broadcasting to N cards` 163 + 164 + This is informational only, not a dedicated text entry field. 165 + 166 + A separate textbox is intentionally rejected because it makes the interaction feel unlike a terminal. 167 + 168 + ### Toolbar 169 + 170 + The canvas toolbar (bottom-right) contains: 171 + 172 + - **Select All** button (`checkmark.rectangle.stack` icon) — tooltip: "Select all cards for broadcast (⌘⌥A)" 173 + - **Arrange** button — preserves card sizes 174 + - **Organize** button — uniform grid layout 175 + 176 + --- 177 + 178 + ## Input Behavior Design 179 + 180 + ## Core Principle 181 + 182 + When multiple cards are selected, the user still types **once** into the primary card. 183 + Canvas mirrors that input to follower cards. 184 + 185 + This should feel like: 186 + 187 + - one real terminal under the cursor 188 + - N-1 follower terminals receiving mirrored input 189 + 190 + --- 191 + 192 + ## IME / Non-English Input Behavior 193 + 194 + This is the most important rule: 195 + 196 + > Followers must receive committed characters/words, not the phonetic keystrokes used to compose them. 197 + 198 + Examples: 199 + 200 + - Chinese Pinyin input should mirror `你好`, not `nihao` 201 + - Japanese input should mirror committed kana/kanji text, not unfinished romaji sequences 202 + 203 + ### IME behavior in v1 204 + 205 + #### Primary card 206 + 207 + The primary card handles the full native IME lifecycle as it does today: 208 + 209 + - marked text / preedit 210 + - candidate window 211 + - commit 212 + - cancel 213 + 214 + #### Follower cards 215 + 216 + Follower cards do **not** render IME preedit/candidate UI. 217 + They receive only the final committed text. 218 + 219 + That means: 220 + 221 + - while the user is composing, followers may show no change yet 222 + - once composition commits, followers receive the committed string immediately 223 + 224 + This is the intended design, not a degradation. 225 + It is the safest way to guarantee that non-English input remains semantically correct. 226 + 227 + --- 228 + 229 + ## Broadcast Categories 230 + 231 + Input fan-out is split into two classes. 232 + 233 + ### 1. Committed text broadcast 234 + 235 + Used for: 236 + 237 + - English text input that arrives as text 238 + - committed IME text 239 + - pasted text (Cmd+V: after Ghostty handles the paste binding in `performKeyEquivalent`, reads `NSPasteboard.general` string and fires `onCommittedText`) 240 + 241 + Behavior: 242 + 243 + - take the committed string from the primary card 244 + - insert the same committed string into each follower card 245 + 246 + ### 2. Normalized special-key broadcast 247 + 248 + Used for: 249 + 250 + - `Enter` 251 + - `Backspace` / `Delete` 252 + - arrow keys (`↑ ↓ ← →`) 253 + - `Tab` 254 + - `Escape` 255 + - common shell control keys (for example `Ctrl-C`, `Ctrl-D`, `Ctrl-L`) 256 + - `Cmd+Backspace` (delete line) 257 + - `Cmd+Arrow` keys (line/word navigation) 258 + 259 + Behavior: 260 + 261 + - normalize the originating primary-card key event into a small mirror-safe model 262 + - replay that normalized input on followers 263 + 264 + ### Whitelisted Cmd combinations 265 + 266 + A static whitelist (`commandAllowedKeyCodes`) controls which Cmd+key combinations pass through. Currently allowed: 267 + 268 + - `Cmd+Backspace` (keyCode 51) 269 + - `Cmd+Arrow Left/Right/Down/Up` (keyCodes 123–126) 270 + 271 + All other Cmd combinations are filtered out. 272 + 273 + ### Explicitly excluded from broadcast 274 + 275 + Do not broadcast: 276 + 277 + - `Cmd` shortcuts not in the whitelist (e.g. `Cmd+C`, `Cmd+W`, `Cmd+Q`) 278 + - menu shortcuts 279 + - window/app commands 280 + - mouse events 281 + - IME marked/preedit updates 282 + 283 + This keeps the feature aligned with terminal input rather than app control. 284 + 285 + --- 286 + 287 + ## Implementation Design 288 + 289 + ## 1. Canvas Selection State 290 + 291 + Selection state lives in `CanvasView` as `@State private var selectionState = CanvasSelectionState()`. 292 + 293 + `CanvasSelectionState` is a pure value type with: 294 + 295 + ```swift 296 + struct CanvasSelectionState: Equatable { 297 + enum Mode: Equatable { case idle, selecting } 298 + 299 + private(set) var mode: Mode 300 + private(set) var selectedTabIDs: Set<TerminalTabID> 301 + private(set) var primaryTabID: TerminalTabID? 302 + private(set) var selectionOrder: [TerminalTabID] 303 + 304 + var isSelecting: Bool // mode == .selecting 305 + var isBroadcasting: Bool // selectedTabIDs.count > 1 306 + 307 + mutating func focusSingle(_ tabID: TerminalTabID) 308 + mutating func toggleSelection(_ tabID: TerminalTabID) 309 + mutating func setPrimary(_ tabID: TerminalTabID) 310 + mutating func selectAll(_ tabIDs: [TerminalTabID]) 311 + mutating func beginBroadcastInteractionIfNeeded() 312 + mutating func clear() 313 + mutating func prune(to visibleTabIDs: Set<TerminalTabID>) 314 + } 315 + ``` 316 + 317 + ### Why keep this in `CanvasView` for v1 318 + 319 + The behavior is Canvas-local and highly UI-driven. 320 + There is no strong need to move it into TCA reducer state yet. 321 + 322 + The pure `CanvasSelectionState` struct makes the transition logic fully testable without SwiftUI. 323 + 324 + --- 325 + 326 + ## 2. Cmd+Click Anywhere on a Card 327 + 328 + ### Problem 329 + 330 + Today the focused terminal content receives hit testing, which means the terminal area would normally steal clicks. 331 + A title-bar-only approach is not acceptable. 332 + 333 + ### Implemented solution: selection shield overlay + per-card visibility 334 + 335 + When either of the following is true: 336 + 337 + - `CommandKeyObserver.isPressed == true`, or 338 + - `selectionMode == .selecting` 339 + 340 + Canvas places a transparent hit-testing layer over every visible card. 341 + 342 + Additionally, during **broadcasting** (multiple cards selected, mode idle): 343 + 344 + - follower cards keep the shield (intercept clicks for `setPrimary` behavior) 345 + - the primary card does **not** show the shield (allows terminal click-through) 346 + 347 + This is computed per-card via `showsSelectionShield(for: TerminalTabID) -> Bool`. 348 + 349 + ### Cmd key detection 350 + 351 + **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. 352 + 353 + 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). 354 + 355 + --- 356 + 357 + ## 3. Make Broadcast Tab-Scoped, Not Worktree-Scoped 358 + 359 + Current terminal commands are mostly scoped by `Worktree`. 360 + Canvas cards are scoped by `TerminalTabID`. 361 + 362 + ### Tab-targeted helpers on `WorktreeTerminalState` 363 + 364 + ```swift 365 + func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool 366 + func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool 367 + ``` 368 + 369 + ### Lookup and broadcast helpers on `WorktreeTerminalManager` 370 + 371 + ```swift 372 + func stateContaining(tabId: TerminalTabID) -> WorktreeTerminalState? 373 + func broadcastCommittedText(_ text: String, from: TerminalTabID, to: Set<TerminalTabID>) -> Int 374 + func broadcastMirroredKey(_ key: MirroredTerminalKey, from: TerminalTabID, to: Set<TerminalTabID>) -> Int 375 + ``` 376 + 377 + Broadcast failures are logged via `SupaLogger` for debugging. 378 + 379 + --- 380 + 381 + ## 4. Broadcast Hooks on `GhosttySurfaceView` 382 + 383 + ### Callbacks 384 + 385 + ```swift 386 + var onCommittedText: ((String) -> Void)? 387 + var onMirroredKey: ((MirroredTerminalKey) -> Void)? 388 + ``` 389 + 390 + - `onCommittedText` fires in `insertText()` after text is committed, and in `performKeyEquivalent` after Ghostty handles a Cmd+V binding. 391 + - `onMirroredKey` fires in `keyDown()` when the event normalizes to a `MirroredTerminalKey`. 392 + 393 + 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. 394 + 395 + ### `MirroredTerminalKey` 396 + 397 + ```swift 398 + struct MirroredTerminalKey: Equatable, Sendable { 399 + enum Kind: Equatable, Sendable { 400 + case enter, backspace, deleteForward 401 + case arrowUp, arrowDown, arrowLeft, arrowRight 402 + case tab, escape, controlCharacter 403 + } 404 + 405 + let kind: Kind 406 + let keyCode: UInt16 407 + let characters: String 408 + let charactersIgnoringModifiers: String 409 + let modifierFlagsRawValue: UInt // raw UInt for Sendable conformance 410 + let isRepeat: Bool 411 + 412 + var modifiers: NSEvent.ModifierFlags { ... } // computed from raw value 413 + } 414 + ``` 415 + 416 + The struct stores `modifierFlagsRawValue` (raw `UInt`) instead of `NSEvent.ModifierFlags` to satisfy `Sendable` conformance, since callbacks cross async boundaries via `Task { @MainActor in }`. 417 + 418 + A static whitelist (`commandAllowedKeyCodes`) allows specific Cmd+key combinations through; all other Cmd events return `nil` from the initializer. 419 + 420 + --- 421 + 422 + ## 5. Safe Follower Insertion APIs 423 + 424 + ```swift 425 + func insertCommittedTextForBroadcast(_ text: String) 426 + func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool 427 + ``` 428 + 429 + - `insertCommittedTextForBroadcast(_:)` writes committed UTF-8 text directly to the surface via `ghostty_surface_text`. 430 + - `applyMirroredKeyForBroadcast(_:)` replays a normalized key on the target surface via `keyDown`/`keyUp` without making it the app first responder. 431 + 432 + Follower cards **never** steal first responder during broadcast. The primary card remains the real focused AppKit responder. 433 + 434 + --- 435 + 436 + ## 6. Event Flow 437 + 438 + ### A. Multi-select click flow 439 + 440 + 1. User holds `Cmd`. 441 + 2. Canvas enables selection shield overlays (may have up to 300ms delay from observer). 442 + 3. User clicks any card region. 443 + 4. `onTap` or `onSelectionTap` fires; both check `NSEvent.modifierFlags.contains(.command)` for reliable detection. 444 + 5. Canvas toggles that `tabID` in `selectedTabIDs`. 445 + 6. Canvas updates `primaryTabID` if needed. 446 + 7. Ghostty does not consume that click. 447 + 448 + ### B. Click during broadcasting (multiple cards selected) 449 + 450 + 1. User clicks a follower card without `Cmd`. 451 + 2. Follower card has selection shield (per-card shield logic). 452 + 3. `handleSelectionShieldTap` detects `isBroadcasting` and the card is selected. 453 + 4. Canvas calls `setPrimary` — promotes the clicked card to primary without clearing multi-selection. 454 + 5. If the user clicks the **primary** card (no shield), the click passes through to the terminal. 455 + 456 + ### C. IME composition on primary card 457 + 458 + 1. User types with IME on the primary card. 459 + 2. Primary card receives `setMarkedText(...)` and updates preedit locally. 460 + 3. No follower update happens yet. 461 + 4. User commits a candidate. 462 + 5. Primary card receives `insertText(...)` with committed text. 463 + 6. `onCommittedText` callback fires, broadcasting committed string to followers. 464 + 465 + ### D. Paste broadcast (Cmd+V) 466 + 467 + 1. User presses Cmd+V on the primary card. 468 + 2. `performKeyEquivalent` detects Cmd+V has a Ghostty binding, calls `keyDown(with: event)`. 469 + 3. Ghostty internally performs `paste_from_clipboard` and writes clipboard content to the primary surface. 470 + 4. After `keyDown` returns, `performKeyEquivalent` reads `NSPasteboard.general.string(forType: .string)` and fires `onCommittedText`. 471 + 4. Broadcast callbacks mirror the pasted text to all follower cards. 472 + 473 + ### E. Enter key broadcast 474 + 475 + 1. Primary card receives Enter. 476 + 2. Primary card submits normally. 477 + 3. `onMirroredKey` emits `.enter` mirrored key. 478 + 4. Followers receive `.enter` via `applyMirroredKeyForBroadcast`. 479 + 480 + --- 481 + 482 + ## 7. Interaction With Existing Canvas Exit Behavior 483 + 484 + Current Canvas exit uses the focused canvas card to decide which worktree/tab to restore. 485 + That continues to use the **primary selected card** via `canvasFocusedWorktreeID`. 486 + 487 + Rules: 488 + 489 + - if multiple cards are selected, exiting Canvas returns to the primary card's owning worktree/tab 490 + - if selection was cleared and no primary remains, Canvas exits to the prior normal fallback behavior 491 + - clicking empty canvas may leave Canvas with 0 selection and 0 focused card; this is acceptable 492 + 493 + --- 494 + 495 + ## Alternatives Considered 496 + 497 + ## Rejected: title-bar-only multi-select 498 + 499 + Rejected because users must be able to select from the terminal area too. 500 + In Canvas, the card is the object, not only its title bar. 501 + 502 + ## Rejected: dedicated batch-input textbox 503 + 504 + Rejected because it makes terminal broadcast feel indirect and unlike the rest of Prowl. 505 + Direct typing is the intended interaction. 506 + 507 + ## Rejected: full raw-event mirroring for IME 508 + 509 + Rejected because it would risk propagating phonetic composition keys (`nihao`, romaji, etc.) instead of committed text. 510 + Correct multilingual output is more important than perfect preedit mirroring. 511 + 512 + ## Rejected: separate `onPasteText` callback 513 + 514 + 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. 515 + 516 + --- 517 + 518 + ## Suggested File-Level Changes 519 + 520 + ### Primary feature files 521 + 522 + - `supacode/Features/Canvas/Views/CanvasView.swift` 523 + - selection state (`CanvasSelectionState`) 524 + - selection-mode transitions via `mutateSelection` 525 + - broadcast callback setup/teardown via `syncBroadcastCallbacks` 526 + - broadcast status UI in toolbar 527 + - selection shield (per-card via `showsSelectionShield(for:)`) 528 + - `Cmd+Opt+A` select all, `Escape` to clear 529 + - `NSEvent.modifierFlags` for reliable Cmd detection in tap handlers 530 + 531 + - `supacode/Features/Canvas/Views/CanvasCardView.swift` 532 + - selected/follower styling (border color, line width, background tint) 533 + - selection shield overlay (`onSelectionTap`) 534 + - normal terminal hit testing preserved outside selection/broadcast mode 535 + 536 + ### Selection model 537 + 538 + - `supacode/Features/Canvas/Models/CanvasSelectionState.swift` 539 + - pure value type for selection transitions 540 + 541 + ### Terminal model / manager 542 + 543 + - `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` 544 + - tab-scoped `insertCommittedText` and `applyMirroredKey` 545 + 546 + - `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` 547 + - `stateContaining(tabId:)` lookup 548 + - `broadcastCommittedText` / `broadcastMirroredKey` fan-out with debug logging 549 + 550 + ### Ghostty bridge 551 + 552 + - `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` 553 + - `onCommittedText` / `onMirroredKey` callbacks 554 + - `insertCommittedTextForBroadcast` / `applyMirroredKeyForBroadcast` follower APIs 555 + - paste broadcast via `onCommittedText` in `paste()` 556 + - IME preedit stays primary-only 557 + 558 + - `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift` 559 + - normalized key model with `Sendable` conformance 560 + - `commandAllowedKeyCodes` whitelist for Cmd+Backspace/Arrow 561 + 562 + --- 563 + 564 + ## Verification Strategy 565 + 566 + ## Automated 567 + 568 + ### Pure selection-state tests (`CanvasSelectionStateTests`) 569 + 570 + - `focusSingle` sets primary and clears selection mode 571 + - `toggleSelection` enters selection mode and appends order 572 + - toggling selected primary promotes previous selection 573 + - toggling last selected card clears state 574 + - `beginBroadcastInteraction` leaves selection set but exits selection mode 575 + - `setPrimary` promotes follower without clearing selection 576 + - `setPrimary` ignores unselected tab 577 + - `selectAll` selects every tab and keeps existing primary 578 + - `selectAll` from empty picks last tab 579 + - `prune` drops missing tabs and preserves newest visible primary 580 + 581 + ### Mirrored key tests (`MirroredTerminalKeyTests`) 582 + 583 + - Enter event normalizes correctly 584 + - Command-modified events are filtered out (e.g. Cmd+C returns nil) 585 + - Cmd+Backspace is allowed through whitelist 586 + - Cmd+Arrow is allowed through whitelist 587 + - Control character event normalizes correctly 588 + - Plain text event does not normalize as mirrored key 589 + 590 + ## Manual 591 + 592 + ### Shell / SSH 593 + 594 + - select 2+ SSH cards 595 + - type a command like `pwd` 596 + - verify all cards receive the same text 597 + - press Enter 598 + - verify all cards execute once 599 + - test `Ctrl-C` 600 + - test `Cmd+Backspace` (delete line) 601 + - test `Cmd+V` paste 602 + 603 + ### Agent prompt comparison 604 + 605 + - select 2+ agent cards 606 + - type the same prompt 607 + - verify all cards receive the same committed prompt text 608 + 609 + ### IME 610 + 611 + - use Chinese Pinyin 612 + - compose text in primary card 613 + - verify followers do not show phonetic intermediate text 614 + - commit the candidate 615 + - verify followers receive committed Chinese text 616 + 617 + - repeat with Japanese input 618 + 619 + ### Selection UX 620 + 621 + - `Cmd+Click` terminal area of focused and unfocused cards 622 + - ordinary click exits selection mode correctly 623 + - blank-canvas click clears selection 624 + - `Cmd+Opt+A` selects all cards 625 + - `Escape` clears broadcast selection 626 + - click follower during broadcasting promotes to primary 627 + - click primary during broadcasting passes through to terminal 628 + - exit Canvas returns to the primary card's worktree/tab 629 + 630 + --- 631 + 632 + ## Risks 633 + 634 + 1. **Ghostty/AppKit event ordering** 635 + - follower replay must not interfere with the primary first responder 636 + 637 + 2. **IME edge cases** 638 + - candidate confirmation behavior may differ by input method 639 + - design intentionally limits follower behavior to committed text 640 + 641 + 3. **Complex TUIs** 642 + - some full-screen or mouse-driven apps may not behave intuitively under broadcast 643 + - acceptable for v1 644 + 645 + 4. **Click/drag interaction overlap** 646 + - card drag gestures and selection clicks must be thresholded cleanly 647 + 648 + 5. **CommandKeyObserver delay** 649 + - 300ms hold delay means shield may not render for fast Cmd+Click 650 + - mitigated by reading `NSEvent.modifierFlags` directly in tap handlers 651 + 652 + --- 653 + 654 + ## Recommended Delivery Shape 655 + 656 + Implement this in slices: 657 + 658 + ### Slice 1 659 + - selection state model 660 + - Cmd+Click anywhere using selection shield 661 + - follower selected styling 662 + - clear/exit behavior 663 + 664 + ### Slice 2 665 + - tab-scoped terminal helpers 666 + - primary/follower broadcast plumbing 667 + - committed text broadcast 668 + - Enter/backspace/arrows/basic control keys 669 + 670 + ### Slice 3 671 + - IME hardening 672 + - paste behavior (Cmd+V broadcast) 673 + - Cmd+Backspace/Arrow whitelist 674 + - select all (Cmd+Opt+A) 675 + - Escape to clear broadcast 676 + - per-card shield during broadcasting 677 + - edge-case polish and manual verification 678 + 679 + This keeps UX validation separate from lower-level Ghostty input fan-out. 680 + 681 + --- 682 + 683 + ## Final Recommendation 684 + 685 + Proceed with a design that treats Canvas multi-select as: 686 + 687 + - **card-level selection anywhere on the card**, not title-bar-only 688 + - **primary-card-driven live broadcast**, not a separate textbox 689 + - **IME commit-text mirroring**, not phonetic keystroke mirroring 690 + 691 + That combination best matches the requested UX while staying implementable in the current Prowl/Ghostty architecture.
+154
doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-implementation-plan.md
··· 1 + # Canvas Multi-Select Broadcast Implementation Plan 2 + 3 + **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. 4 + 5 + **Scope:** 6 + - In: 7 + - Canvas-local multi-selection state and transitions 8 + - Cmd+Click selection across full card area 9 + - Primary vs follower selected styling 10 + - Broadcast of committed text plus a small set of normalized special keys 11 + - Whitelisted Cmd+key broadcast (Cmd+Backspace, Cmd+Arrow) 12 + - Cmd+V paste broadcast via pasteboard string 13 + - Cmd+Opt+A select all, Escape to clear 14 + - Per-card selection shield during broadcasting 15 + - IME-safe follower behavior using committed text only 16 + - Tests for selection state transitions and input normalization/filtering 17 + - Out: 18 + - Mouse broadcast 19 + - Full TUI parity for all applications 20 + - Follower-side IME candidate/preedit UI 21 + 22 + **Architecture:** 23 + - Keep selection state local to Canvas as `@State var selectionState = CanvasSelectionState()`. 24 + - 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). 25 + - Keep one primary card as the real first responder; mirror input from it to follower cards. 26 + - Use `NSEvent.modifierFlags` for immediate Cmd detection in tap handlers (bypasses `CommandKeyObserver`'s 300ms hold delay). 27 + - Introduce `MirroredTerminalKey` (Sendable) for normalized key replay with a Cmd-key whitelist. 28 + - Treat IME specially: only committed text fans out; preedit stays primary-only. 29 + - Broadcast paste content by reading `NSPasteboard.general` string in `paste()` and firing `onCommittedText`. 30 + 31 + **Acceptance / Verification:** 32 + - Cmd+Click anywhere on a card toggles selection. 33 + - Non-Cmd click exits selection mode and returns to single-card interaction. 34 + - Non-Cmd click on a follower during broadcasting promotes it to primary. 35 + - Non-Cmd click on the primary during broadcasting passes through to terminal. 36 + - Clicking blank canvas clears selection and focus. 37 + - Escape clears broadcast selection. 38 + - Cmd+Opt+A selects all visible cards. 39 + - Multiple selected cards receive mirrored committed text. 40 + - Cmd+V paste text is broadcast to followers. 41 + - Cmd+Backspace and Cmd+Arrow are broadcast to followers. 42 + - Followers receive committed Chinese/Japanese text, not phonetic intermediate input. 43 + - Build passes and targeted tests pass. 44 + 45 + ## Task 1: Add pure Canvas selection state machine ✅ 46 + 47 + **Files:** 48 + - Created: `supacode/Features/Canvas/Models/CanvasSelectionState.swift` 49 + - Created: `supacodeTests/CanvasSelectionStateTests.swift` 50 + 51 + **Delivered:** 52 + - Pure `CanvasSelectionState` struct with `focusSingle`, `toggleSelection`, `setPrimary`, `selectAll`, `beginBroadcastInteractionIfNeeded`, `clear`, `prune`. 53 + - 10 tests covering all state transitions. 54 + 55 + ## Task 2: Integrate selection model into CanvasView ✅ 56 + 57 + **Files:** 58 + - Modified: `supacode/Features/Canvas/Views/CanvasView.swift` 59 + 60 + **Delivered:** 61 + - Replaced `focusedTabID` with `selectionState: CanvasSelectionState`. 62 + - `mutateSelection` helper centralizes state mutation, pruning, focus sync, and callback sync. 63 + - z-order respects primary > selected > unselected. 64 + 65 + ## Task 3: Add selected/follower visuals and selection shield hooks ✅ 66 + 67 + **Files:** 68 + - Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift` 69 + 70 + **Delivered:** 71 + - Primary: 2pt accent border. Follower: 1.5pt accent at 65% opacity + background tint. 72 + - `selectionShield` overlay intercepts clicks via `onSelectionTap`. 73 + - Resize handles hidden when shield is active. 74 + - Terminal hit testing: `allowsHitTesting(isFocused && !showsSelectionShield)`. 75 + 76 + ## Task 4: Wire Cmd+Click anywhere on card ✅ 77 + 78 + **Files:** 79 + - Modified: `supacode/Features/Canvas/Views/CanvasView.swift` 80 + - Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift` 81 + 82 + **Delivered:** 83 + - `showsSelectionShield(for:)` is per-card: all cards during `Cmd`/selecting; only followers during broadcasting. 84 + - `onTap` checks `NSEvent.modifierFlags.contains(.command)` directly for reliable Cmd detection (bypasses 300ms observer delay). 85 + - `handleSelectionShieldTap` dispatches to `toggleSelection`, `setPrimary`, or `focusSingle` based on Cmd state and broadcasting state. 86 + - Blank-canvas click clears selection. 87 + 88 + ## Task 5: Add normalized mirrored-key model ✅ 89 + 90 + **Files:** 91 + - Created: `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift` 92 + - Created: `supacodeTests/MirroredTerminalKeyTests.swift` 93 + 94 + **Delivered:** 95 + - `MirroredTerminalKey: Equatable, Sendable` with kinds: enter, backspace, deleteForward, arrows, tab, escape, controlCharacter. 96 + - Stores `modifierFlagsRawValue: UInt` for Sendable (computed `modifiers` property). 97 + - `commandAllowedKeyCodes` whitelist: Cmd+Backspace (51), Cmd+Arrow (123–126). All other Cmd combos rejected. 98 + - 6 tests covering normalization, Cmd filtering, whitelist, and plain-text rejection. 99 + 100 + ## Task 6: Add Ghostty broadcast hooks and safe follower APIs ✅ 101 + 102 + **Files:** 103 + - Modified: `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` 104 + 105 + **Delivered:** 106 + - `onCommittedText` callback: fires in `insertText()` and in `paste()` (reads pasteboard string). 107 + - `onMirroredKey` callback: fires in `keyDown()` for normalized keys. 108 + - `insertCommittedTextForBroadcast(_:)`: writes UTF-8 text via `ghostty_surface_text`. 109 + - `applyMirroredKeyForBroadcast(_:)`: replays NSEvent via `keyDown`/`keyUp` without stealing responder. 110 + 111 + ## Task 7: Add tab-scoped terminal broadcast helpers ✅ 112 + 113 + **Files:** 114 + - Modified: `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` 115 + - Modified: `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` 116 + 117 + **Delivered:** 118 + - `WorktreeTerminalState.insertCommittedText(_:in:)` and `applyMirroredKey(_:in:)`. 119 + - `WorktreeTerminalManager.stateContaining(tabId:)` lookup. 120 + - `broadcastCommittedText` / `broadcastMirroredKey` fan-out methods with `@discardableResult` return count. 121 + - Debug logging via `SupaLogger` for broadcast failures. 122 + 123 + ## Task 8: Connect primary-card input to follower broadcast ✅ 124 + 125 + **Files:** 126 + - Modified: `supacode/Features/Canvas/Views/CanvasView.swift` 127 + 128 + **Delivered:** 129 + - `syncBroadcastCallbacks` sets `onCommittedText`/`onMirroredKey` on primary surface's leaves only when broadcasting. 130 + - `clearBroadcastCallbacks` nils out all callbacks on all surfaces. 131 + - Callbacks use explicit capture list with `beginBroadcast` closure for safe `selectionState` mutation. 132 + - Callbacks re-sync after split operations on primary card. 133 + - Callbacks sync on `onAppear`, `onChange(allCardKeys)`, `onChange(allTabIDs)`, `mutateSelection`, `pruneSelection`, `deactivateCanvas`. 134 + 135 + ## Task 9: Add Canvas keyboard shortcuts and toolbar ✅ 136 + 137 + **Files:** 138 + - Modified: `supacode/Features/Canvas/Views/CanvasView.swift` 139 + 140 + **Delivered:** 141 + - `.onKeyPress(.escape)`: clears selection when broadcasting. 142 + - `.onKeyPress("a", phases: .down)` with `keyPress.modifiers == [.command, .shift]`: selects all cards. 143 + - Toolbar: select-all button + broadcasting badge + arrange + organize. 144 + 145 + ## Task 10: Polish and verification ✅ 146 + 147 + **Delivered:** 148 + - Fixed reversed canvas scroll direction (removed incorrect delta negation in `CanvasScrollContainerView`). 149 + - Fixed unsafe `selectionState` capture in broadcast callbacks. 150 + - Made `MirroredTerminalKey` Sendable via raw UInt storage. 151 + - Added Cmd+Backspace/Arrow whitelist. 152 + - Added Cmd+V paste broadcast. 153 + - All tests pass. Build passes. Lint passes. 154 + - Design and implementation plan docs updated to match final implementation.
+93
supacode/Features/Canvas/Models/CanvasSelectionState.swift
··· 1 + import Foundation 2 + 3 + struct CanvasSelectionState: Equatable { 4 + enum Mode: Equatable { 5 + case idle 6 + case selecting 7 + } 8 + 9 + private(set) var mode: Mode = .idle 10 + private(set) var selectedTabIDs: Set<TerminalTabID> = [] 11 + private(set) var primaryTabID: TerminalTabID? 12 + private(set) var selectionOrder: [TerminalTabID] = [] 13 + 14 + var isSelecting: Bool { 15 + mode == .selecting 16 + } 17 + 18 + var isBroadcasting: Bool { 19 + selectedTabIDs.count > 1 20 + } 21 + 22 + mutating func focusSingle(_ tabID: TerminalTabID) { 23 + mode = .idle 24 + selectedTabIDs = [tabID] 25 + primaryTabID = tabID 26 + selectionOrder = [tabID] 27 + } 28 + 29 + mutating func toggleSelection(_ tabID: TerminalTabID) { 30 + mode = .selecting 31 + if selectedTabIDs.contains(tabID) { 32 + selectedTabIDs.remove(tabID) 33 + selectionOrder.removeAll { $0 == tabID } 34 + if selectedTabIDs.isEmpty { 35 + mode = .idle 36 + primaryTabID = nil 37 + selectionOrder = [] 38 + } else if primaryTabID == tabID { 39 + primaryTabID = selectionOrder.last ?? selectedTabIDs.first 40 + } 41 + return 42 + } 43 + 44 + selectedTabIDs.insert(tabID) 45 + selectionOrder.removeAll { $0 == tabID } 46 + selectionOrder.append(tabID) 47 + primaryTabID = tabID 48 + } 49 + 50 + mutating func setPrimary(_ tabID: TerminalTabID) { 51 + guard selectedTabIDs.contains(tabID) else { return } 52 + primaryTabID = tabID 53 + selectionOrder.removeAll { $0 == tabID } 54 + selectionOrder.append(tabID) 55 + } 56 + 57 + mutating func selectAll(_ tabIDs: [TerminalTabID]) { 58 + guard !tabIDs.isEmpty else { return } 59 + mode = .idle 60 + selectedTabIDs = Set(tabIDs) 61 + selectionOrder = tabIDs 62 + if let primaryTabID, selectedTabIDs.contains(primaryTabID) { 63 + // Keep current primary if it's still in the set. 64 + selectionOrder.removeAll { $0 == primaryTabID } 65 + selectionOrder.append(primaryTabID) 66 + } else { 67 + primaryTabID = tabIDs.last 68 + } 69 + } 70 + 71 + mutating func beginBroadcastInteractionIfNeeded() { 72 + guard isSelecting, selectedTabIDs.count > 1 else { return } 73 + mode = .idle 74 + } 75 + 76 + mutating func clear() { 77 + mode = .idle 78 + selectedTabIDs = [] 79 + primaryTabID = nil 80 + selectionOrder = [] 81 + } 82 + 83 + mutating func prune(to visibleTabIDs: Set<TerminalTabID>) { 84 + selectedTabIDs.formIntersection(visibleTabIDs) 85 + selectionOrder.removeAll { !visibleTabIDs.contains($0) } 86 + if let primaryTabID, !visibleTabIDs.contains(primaryTabID) { 87 + self.primaryTabID = selectionOrder.last ?? selectedTabIDs.first 88 + } 89 + if selectedTabIDs.isEmpty { 90 + clear() 91 + } 92 + } 93 + }
+67 -16
supacode/Features/Canvas/Views/CanvasCardView.swift
··· 6 6 let worktreeName: String 7 7 let tree: SplitTree<GhosttySurfaceView> 8 8 let isFocused: Bool 9 + let isSelected: Bool 9 10 let hasUnseenNotification: Bool 10 11 let cardSize: CGSize 11 12 let canvasScale: CGFloat 13 + let showsSelectionShield: Bool 12 14 let onTap: () -> Void 15 + let onSelectionTap: () -> Void 13 16 let onDragCommit: (CGSize) -> Void 14 17 let onResize: (CardResizeEdge, CGSize) -> Void 15 18 let onResizeEnd: () -> Void ··· 48 51 terminalContent 49 52 } 50 53 .frame(width: cardSize.width, height: cardSize.height + titleBarHeight) 54 + .background(cardBackground) 51 55 .clipShape(.rect(cornerRadius: cornerRadius)) 52 56 .overlay { 53 - RoundedRectangle(cornerRadius: cornerRadius) 54 - .stroke(isFocused ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isFocused ? 2 : 1) 57 + ZStack { 58 + RoundedRectangle(cornerRadius: cornerRadius) 59 + .stroke(borderColor, lineWidth: borderLineWidth) 60 + if !showsSelectionShield { 61 + resizeHandles 62 + } 63 + if showsSelectionShield { 64 + selectionShield 65 + } 66 + } 55 67 } 56 68 .compositingGroup() 57 69 .contentShape(.rect) ··· 61 73 x: dragTranslation.width / canvasScale, 62 74 y: dragTranslation.height / canvasScale 63 75 ) 64 - .overlay { resizeHandles } 76 + } 77 + 78 + private var borderColor: Color { 79 + if isFocused { 80 + .accentColor 81 + } else if isSelected { 82 + .accentColor.opacity(0.65) 83 + } else { 84 + .secondary.opacity(0.3) 85 + } 86 + } 87 + 88 + private var borderLineWidth: CGFloat { 89 + if isFocused { 90 + 2 91 + } else if isSelected { 92 + 1.5 93 + } else { 94 + 1 95 + } 96 + } 97 + 98 + @ViewBuilder 99 + private var cardBackground: some View { 100 + if isSelected && !isFocused { 101 + Color.accentColor.opacity(0.08) 102 + } else { 103 + Color.clear 104 + } 65 105 } 66 106 67 107 private var titleBar: some View { ··· 78 118 .padding(.horizontal, 8) 79 119 .frame(height: titleBarHeight) 80 120 .frame(maxWidth: .infinity) 81 - .background { 82 - if hasUnseenNotification { 83 - Color.orange.opacity(0.3) 84 - } 85 - } 86 - .background(.bar) 121 + .background(titleBarBackground) 87 122 .accessibilityAddTraits(.isButton) 88 123 .onTapGesture { onTitleBarTap() } 89 124 .gesture( ··· 101 136 ) 102 137 } 103 138 139 + @ViewBuilder 140 + private var titleBarBackground: some View { 141 + ZStack { 142 + if hasUnseenNotification { 143 + Color.orange.opacity(0.3) 144 + } 145 + if isSelected && !isFocused { 146 + Color.accentColor.opacity(0.12) 147 + } 148 + Rectangle() 149 + .fill(.bar) 150 + .opacity(0.9) 151 + } 152 + } 153 + 104 154 private var terminalContent: some View { 105 155 TerminalSplitTreeView(tree: tree, pinnedSize: cardSize, action: onSplitOperation) 106 156 .frame(width: cardSize.width, height: cardSize.height) 107 - .allowsHitTesting(isFocused) 157 + .allowsHitTesting(isFocused && !showsSelectionShield) 158 + } 159 + 160 + private var selectionShield: some View { 161 + Color.clear 162 + .contentShape(.rect) 163 + .accessibilityAddTraits(.isButton) 164 + .onTapGesture { onSelectionTap() } 108 165 } 109 166 110 167 // MARK: - Resize Handles ··· 242 299 if hovering { 243 300 cursor.push() 244 301 } else { 245 - NSCursor.pop() 246 - } 247 - } 248 - .onDisappear { 249 - if isHovered { 250 - isHovered = false 251 302 NSCursor.pop() 252 303 } 253 304 }
+218 -45
supacode/Features/Canvas/Views/CanvasView.swift
··· 2 2 import SwiftUI 3 3 4 4 struct CanvasView: View { 5 + @Environment(CommandKeyObserver.self) private var commandKeyObserver 6 + 5 7 let terminalManager: WorktreeTerminalManager 6 8 var onExitToTab: () -> Void = {} 7 9 @State private var layoutStore = CanvasLayoutStore() ··· 10 12 @State private var lastCanvasOffset: CGSize = .zero 11 13 @State private var canvasScale: CGFloat = 1.0 12 14 @State private var lastCanvasScale: CGFloat = 1.0 13 - @State private var focusedTabID: TerminalTabID? 15 + @State private var selectionState = CanvasSelectionState() 14 16 @State private var lastTitleBarTapDate: Date = .distantPast 15 17 @State private var activeResize: [TerminalTabID: ActiveResize] = [:] 16 18 @State private var hasPerformedInitialFit = false ··· 28 30 GeometryReader { _ in 29 31 let activeStates = terminalManager.activeWorktreeStates 30 32 let allCardKeys = collectCardKeys(from: activeStates) 33 + let allTabIDs = collectVisibleTabIDs(from: activeStates) 31 34 32 - // Background layer: handles canvas pan and tap-to-unfocus. 35 + // Background layer: handles canvas pan and tap-to-clear. 33 36 Color.clear 34 - .onAppear { ensureLayouts(for: allCardKeys) } 37 + .onAppear { 38 + ensureLayouts(for: allCardKeys) 39 + pruneSelection(to: Set(allTabIDs), states: activeStates) 40 + syncBroadcastCallbacks(states: activeStates) 41 + } 35 42 .onChange(of: allCardKeys) { _, newKeys in 36 43 if newKeys.isEmpty { 37 44 CanvasLayoutStore.hasAutoArrangedInSession = false 38 45 } 39 46 ensureLayouts(for: newKeys) 47 + syncBroadcastCallbacks(states: activeStates) 48 + } 49 + .onChange(of: allTabIDs) { _, newTabIDs in 50 + pruneSelection(to: Set(newTabIDs), states: activeStates) 40 51 } 41 52 .contentShape(.rect) 42 53 .accessibilityAddTraits(.isButton) 43 - .onTapGesture { unfocusAll() } 54 + .onTapGesture { clearSelection(states: activeStates) } 44 55 .gesture(canvasPanGesture) 45 56 46 57 // Cards layer: one card per open tab across all worktrees. ··· 60 71 repositoryName: Repository.name(for: state.repositoryRootURL), 61 72 worktreeName: tab.title, 62 73 tree: tree, 63 - isFocused: focusedTabID == tab.id, 74 + isFocused: selectionState.primaryTabID == tab.id, 75 + isSelected: selectionState.selectedTabIDs.contains(tab.id), 64 76 hasUnseenNotification: state.hasUnseenNotification(for: tab.id), 65 77 cardSize: resized.size, 66 78 canvasScale: canvasScale, 79 + showsSelectionShield: showsSelectionShield(for: tab.id), 67 80 onTap: { 68 - if let activeSurface = state.surfaceView(for: tab.id) { 69 - focusCard(tab.id, surfaceView: activeSurface, states: activeStates) 81 + let cmdHeld = NSEvent.modifierFlags.contains(.command) 82 + if cmdHeld { 83 + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) 84 + } else { 85 + focusSingleCard(tab.id, surfaceState: state, states: activeStates) 70 86 } 87 + }, 88 + onSelectionTap: { 89 + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) 71 90 }, 72 91 onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, 73 92 onResize: { edge, translation in ··· 82 101 onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, 83 102 onSplitOperation: { operation in 84 103 state.performSplitOperation(operation, in: tab.id) 104 + if selectionState.isBroadcasting { 105 + syncBroadcastCallbacks(states: activeStates) 106 + } 85 107 }, 86 108 onTitleBarTap: { 87 - let wasAlreadyFocused = focusedTabID == tab.id 88 - if let activeSurface = state.surfaceView(for: tab.id) { 89 - focusCard(tab.id, surfaceView: activeSurface, states: activeStates) 90 - } 109 + let wasAlreadyFocused = 110 + selectionState.primaryTabID == tab.id 111 + && selectionState.selectedTabIDs.count <= 1 112 + focusSingleCard(tab.id, surfaceState: state, states: activeStates) 91 113 let now = Date() 92 114 if wasAlreadyFocused, 93 115 now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval ··· 102 124 x: screenCenter.x - resized.size.width / 2, 103 125 y: screenCenter.y - cardTotalHeight / 2 104 126 ) 105 - .zIndex(focusedTabID == tab.id ? 1 : 0) 127 + .zIndex(zIndex(for: tab.id)) 106 128 } 107 129 } 108 130 } ··· 126 148 .overlay(alignment: .bottomTrailing) { 127 149 canvasToolbar 128 150 } 151 + .onKeyPress(.escape) { 152 + guard selectionState.isBroadcasting else { return .ignored } 153 + clearSelection(states: terminalManager.activeWorktreeStates) 154 + return .handled 155 + } 156 + .onKeyPress("a", phases: .down) { keyPress in 157 + guard keyPress.modifiers == [.command, .option] else { return .ignored } 158 + selectAllCards() 159 + return .handled 160 + } 129 161 .task { activateCanvas() } 130 162 .onDisappear { deactivateCanvas() } 163 + } 164 + 165 + private func showsSelectionShield(for tabID: TerminalTabID) -> Bool { 166 + if commandKeyObserver.isPressed { return true } 167 + if selectionState.isSelecting { return true } 168 + if selectionState.isBroadcasting, selectionState.primaryTabID != tabID { return true } 169 + return false 131 170 } 132 171 133 172 // MARK: - Canvas Gestures ··· 268 307 } 269 308 } 270 309 310 + private func collectVisibleTabIDs(from states: [WorktreeTerminalState]) -> [TerminalTabID] { 311 + states.flatMap { state in 312 + state.tabManager.tabs.compactMap { tab in 313 + state.surfaceView(for: tab.id) != nil ? tab.id : nil 314 + } 315 + } 316 + } 317 + 271 318 /// Reset all card positions to a clean grid layout (uniform sizes). 272 319 private func organizeCards() { 273 320 let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) ··· 357 404 358 405 private var canvasToolbar: some View { 359 406 HStack(spacing: 8) { 407 + if selectionState.isBroadcasting { 408 + Label( 409 + "Broadcasting to \(selectionState.selectedTabIDs.count) cards", 410 + systemImage: "dot.radiowaves.left.and.right" 411 + ) 412 + .font(.callout) 413 + .padding(.horizontal, 10) 414 + .padding(.vertical, 6) 415 + .background(.bar, in: Capsule()) 416 + } 417 + 418 + Button { 419 + selectAllCards() 420 + } label: { 421 + Image(systemName: "checkmark.rectangle.stack") 422 + .font(.body) 423 + .accessibilityLabel("Select All") 424 + } 425 + .buttonStyle(.bordered) 426 + .help("Select all cards for broadcast (⌘⌥A)") 427 + 360 428 Button { 361 429 withAnimation(.easeInOut(duration: 0.2)) { 362 430 arrangeCards() ··· 386 454 .padding() 387 455 } 388 456 457 + private func zIndex(for tabID: TerminalTabID) -> Double { 458 + if selectionState.primaryTabID == tabID { 459 + return 2 460 + } 461 + if selectionState.selectedTabIDs.contains(tabID) { 462 + return 1 463 + } 464 + return 0 465 + } 466 + 389 467 // MARK: - Drag 390 468 391 469 private func commitDrag(for cardKey: String, translation: CGSize) { ··· 413 491 } 414 492 } 415 493 416 - // MARK: - Focus 494 + private func selectAllCards() { 495 + let activeStates = terminalManager.activeWorktreeStates 496 + let allTabIDs = collectVisibleTabIDs(from: activeStates) 497 + guard allTabIDs.count > 1 else { return } 498 + mutateSelection(states: activeStates) { state in 499 + state.selectAll(allTabIDs) 500 + } 501 + } 502 + 503 + // MARK: - Selection and Focus 504 + 505 + private func focusSingleCard( 506 + _ tabID: TerminalTabID, 507 + surfaceState _: WorktreeTerminalState, 508 + states: [WorktreeTerminalState] 509 + ) { 510 + mutateSelection(states: states) { state in 511 + state.focusSingle(tabID) 512 + } 513 + } 417 514 418 - private func focusCard( 515 + private func handleSelectionShieldTap( 419 516 _ tabID: TerminalTabID, 420 - surfaceView: GhosttySurfaceView, 517 + surfaceState _: WorktreeTerminalState, 421 518 states: [WorktreeTerminalState] 422 519 ) { 423 - let previousTabID = focusedTabID 424 - focusedTabID = tabID 520 + let cmdHeld = NSEvent.modifierFlags.contains(.command) 521 + mutateSelection(states: states) { state in 522 + if cmdHeld { 523 + state.toggleSelection(tabID) 524 + } else if state.isBroadcasting, state.selectedTabIDs.contains(tabID) { 525 + state.setPrimary(tabID) 526 + } else { 527 + state.focusSingle(tabID) 528 + } 529 + } 530 + } 425 531 426 - // Sync the tab selection on the owning worktree so that exiting canvas 427 - // (via toggleCanvas → selectWorktree) will focus the correct tab. 428 - if let ownerState = states.first(where: { $0.surfaceView(for: tabID) != nil }) { 429 - ownerState.tabManager.selectTab(tabID) 430 - terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID 532 + private func clearSelection(states: [WorktreeTerminalState]) { 533 + mutateSelection(states: states) { state in 534 + state.clear() 431 535 } 536 + } 432 537 433 - // Unfocus all surfaces in the previous card's split tree 434 - if let previousTabID, previousTabID != tabID, 435 - let previousState = states.first(where: { $0.surfaceView(for: previousTabID) != nil }) 436 - { 437 - for surface in previousState.splitTree(for: previousTabID).leaves() { 438 - surface.focusDidChange(false) 439 - } 538 + private func pruneSelection(to visibleTabIDs: Set<TerminalTabID>, states: [WorktreeTerminalState]) { 539 + let previousPrimaryTabID = selectionState.primaryTabID 540 + selectionState.prune(to: visibleTabIDs) 541 + syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) 542 + syncBroadcastCallbacks(states: states) 543 + } 544 + 545 + private func mutateSelection( 546 + states: [WorktreeTerminalState], 547 + mutation: (inout CanvasSelectionState) -> Void 548 + ) { 549 + let previousPrimaryTabID = selectionState.primaryTabID 550 + mutation(&selectionState) 551 + selectionState.prune(to: Set(collectVisibleTabIDs(from: states))) 552 + syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) 553 + syncBroadcastCallbacks(states: states) 554 + } 555 + 556 + private func syncPrimaryFocus( 557 + from previousTabID: TerminalTabID?, 558 + to newTabID: TerminalTabID?, 559 + states: [WorktreeTerminalState] 560 + ) { 561 + if let previousTabID, previousTabID != newTabID { 562 + unfocusTab(previousTabID, states: states) 440 563 } 441 564 565 + guard let newTabID, 566 + let ownerState = states.first(where: { $0.surfaceView(for: newTabID) != nil }), 567 + let surfaceView = ownerState.surfaceView(for: newTabID) 568 + else { 569 + terminalManager.canvasFocusedWorktreeID = nil 570 + return 571 + } 572 + 573 + ownerState.tabManager.selectTab(newTabID) 574 + terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID 442 575 surfaceView.focusDidChange(true) 443 576 surfaceView.requestFocus() 444 577 } 445 578 446 - private func unfocusAll() { 447 - guard let previousTabID = focusedTabID else { return } 448 - focusedTabID = nil 449 - if let state = terminalManager.activeWorktreeStates 450 - .first(where: { $0.surfaceView(for: previousTabID) != nil }) 451 - { 452 - for surface in state.splitTree(for: previousTabID).leaves() { 453 - surface.focusDidChange(false) 579 + private func unfocusTab(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { 580 + guard let state = states.first(where: { $0.surfaceView(for: tabID) != nil }) else { return } 581 + for surface in state.splitTree(for: tabID).leaves() { 582 + surface.focusDidChange(false) 583 + } 584 + } 585 + 586 + private func syncBroadcastCallbacks(states: [WorktreeTerminalState]) { 587 + clearBroadcastCallbacks(states: states) 588 + 589 + guard selectionState.isBroadcasting, 590 + let primaryTabID = selectionState.primaryTabID, 591 + let primaryState = terminalManager.stateContaining(tabId: primaryTabID) 592 + else { 593 + return 594 + } 595 + 596 + let selectedTabIDs = selectionState.selectedTabIDs 597 + let beginBroadcast = { selectionState.beginBroadcastInteractionIfNeeded() } 598 + for primarySurface in primaryState.splitTree(for: primaryTabID).leaves() { 599 + primarySurface.onCommittedText = { [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] text in 600 + Task { @MainActor in 601 + beginBroadcast() 602 + terminalManager.broadcastCommittedText(text, from: primaryTabID, to: selectedTabIDs) 603 + } 604 + } 605 + primarySurface.onMirroredKey = { 606 + [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] mirroredKey in 607 + Task { @MainActor in 608 + beginBroadcast() 609 + terminalManager.broadcastMirroredKey(mirroredKey, from: primaryTabID, to: selectedTabIDs) 610 + } 611 + } 612 + } 613 + } 614 + 615 + private func clearBroadcastCallbacks(states: [WorktreeTerminalState]) { 616 + for state in states { 617 + for tab in state.tabManager.tabs { 618 + for surface in state.splitTree(for: tab.id).leaves() { 619 + surface.onCommittedText = nil 620 + surface.onMirroredKey = nil 621 + } 454 622 } 455 623 } 456 624 } ··· 465 633 // Auto-focus the card that was active before entering canvas. 466 634 if let selectedID = terminalManager.selectedWorktreeID, 467 635 let state = activeStates.first(where: { $0.worktreeID == selectedID }), 468 - let tabID = state.tabManager.selectedTabId, 469 - let surface = state.surfaceView(for: tabID) 636 + let tabID = state.tabManager.selectedTabId 470 637 { 471 - focusCard(tabID, surfaceView: surface, states: activeStates) 638 + selectionState.focusSingle(tabID) 639 + syncPrimaryFocus(from: nil, to: tabID, states: activeStates) 640 + } else { 641 + selectionState.clear() 642 + syncBroadcastCallbacks(states: activeStates) 472 643 } 473 644 474 645 for state in activeStates { ··· 485 656 } 486 657 487 658 private func deactivateCanvas() { 488 - focusedTabID = nil 659 + clearBroadcastCallbacks(states: terminalManager.activeWorktreeStates) 660 + selectionState.clear() 489 661 // Don't occlude surfaces here. In SwiftUI's if/else view swap, 490 662 // onAppear fires before onDisappear, so occluding here would undo 491 663 // WorktreeTerminalTabsView.onAppear's syncFocus() and cause blank ··· 557 729 var scrollCoordinator: CanvasScrollCoordinator? 558 730 559 731 override func scrollWheel(with event: NSEvent) { 560 - scrollCoordinator?.handleScroll( 561 - deltaX: event.scrollingDeltaX, 562 - deltaY: event.scrollingDeltaY 563 - ) 732 + if event.phase == .began || event.phase == .changed || event.phase == .mayBegin || event.momentumPhase != [] { 733 + scrollCoordinator?.handleScroll(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY) 734 + return 735 + } 736 + super.scrollWheel(with: event) 564 737 } 565 738 }
+38
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 255 255 states[worktreeID] 256 256 } 257 257 258 + func stateContaining(tabId: TerminalTabID) -> WorktreeTerminalState? { 259 + activeWorktreeStates.first { $0.surfaceView(for: tabId) != nil } 260 + } 261 + 262 + @discardableResult 263 + func broadcastCommittedText( 264 + _ text: String, 265 + from primaryTabID: TerminalTabID, 266 + to selectedTabIDs: Set<TerminalTabID> 267 + ) -> Int { 268 + var mirrored = 0 269 + for tabId in selectedTabIDs where tabId != primaryTabID { 270 + if stateContaining(tabId: tabId)?.insertCommittedText(text, in: tabId) == true { 271 + mirrored += 1 272 + } else { 273 + terminalLogger.debug("Broadcast text failed for tab \(tabId)") 274 + } 275 + } 276 + return mirrored 277 + } 278 + 279 + @discardableResult 280 + func broadcastMirroredKey( 281 + _ key: MirroredTerminalKey, 282 + from primaryTabID: TerminalTabID, 283 + to selectedTabIDs: Set<TerminalTabID> 284 + ) -> Int { 285 + var mirrored = 0 286 + for tabId in selectedTabIDs where tabId != primaryTabID { 287 + if stateContaining(tabId: tabId)?.applyMirroredKey(key, in: tabId) == true { 288 + mirrored += 1 289 + } else { 290 + terminalLogger.debug("Broadcast key failed for tab \(tabId)") 291 + } 292 + } 293 + return mirrored 294 + } 295 + 258 296 func taskStatus(for worktreeID: Worktree.ID) -> WorktreeTaskStatus? { 259 297 states[worktreeID]?.taskStatus 260 298 }
+13
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 80 80 return surfaces[surfaceId] 81 81 } 82 82 83 + @discardableResult 84 + func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool { 85 + guard let surface = surfaceView(for: tabId) else { return false } 86 + surface.insertCommittedTextForBroadcast(text) 87 + return true 88 + } 89 + 90 + @discardableResult 91 + func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool { 92 + guard let surface = surfaceView(for: tabId) else { return false } 93 + return surface.applyMirroredKeyForBroadcast(key) 94 + } 95 + 83 96 var taskStatus: WorktreeTaskStatus { 84 97 tabIsRunningById.values.contains(true) ? .running : .idle 85 98 }
+46 -5
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 119 119 } 120 120 var onFocusChange: ((Bool) -> Void)? 121 121 var onKeyInput: (() -> Void)? 122 + var onCommittedText: ((String) -> Void)? 123 + var onMirroredKey: ((MirroredTerminalKey) -> Void)? 122 124 123 125 private var accessibilityPaneIndexHelp: String? 124 126 ··· 562 564 } 563 565 bridge.state.bellCount = 0 564 566 onKeyInput?() 567 + if let mirroredKey = MirroredTerminalKey(event: event) { 568 + onMirroredKey?(mirroredKey) 569 + } 565 570 let (translationEvent, translationMods) = translationState(event, surface: surface) 566 571 let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS 567 572 keyTextAccumulator = [] ··· 994 999 return true 995 1000 } 996 1001 keyDown(with: event) 1002 + // Ghostty handled paste internally; broadcast the pasted text to followers. 1003 + if onCommittedText != nil, 1004 + event.modifierFlags.contains(.command), 1005 + event.charactersIgnoringModifiers == "v", 1006 + let text = NSPasteboard.general.string(forType: .string), 1007 + !text.isEmpty 1008 + { 1009 + onCommittedText?(text) 1010 + } 997 1011 return true 998 1012 } 999 1013 ··· 1197 1211 1198 1212 @IBAction func paste(_ sender: Any?) { 1199 1213 performBindingAction("paste_from_clipboard") 1214 + if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { 1215 + onCommittedText?(text) 1216 + } 1200 1217 } 1201 1218 1202 1219 @IBAction func pasteSelection(_ sender: Any?) { ··· 1554 1571 return window.convertToScreen(winRect) 1555 1572 } 1556 1573 1557 - func insertText(_ string: Any, replacementRange: NSRange) { 1574 + func insertText(_ string: Any, replacementRange _: NSRange) { 1558 1575 guard NSApp.currentEvent != nil else { return } 1559 - guard let surface else { return } 1576 + guard surface != nil else { return } 1560 1577 var chars = "" 1561 1578 switch string { 1562 1579 case let attributedText as NSAttributedString: ··· 1570 1587 if var acc = keyTextAccumulator { 1571 1588 acc.append(chars) 1572 1589 keyTextAccumulator = acc 1590 + onCommittedText?(chars) 1573 1591 return 1574 1592 } 1575 - let len = chars.utf8CString.count 1576 - if len == 0 { return } 1577 - chars.withCString { ptr in 1593 + insertCommittedTextForBroadcast(chars) 1594 + onCommittedText?(chars) 1595 + } 1596 + 1597 + func insertCommittedTextForBroadcast(_ text: String) { 1598 + guard let surface else { return } 1599 + guard !text.isEmpty else { return } 1600 + unmarkText() 1601 + let len = text.utf8CString.count 1602 + guard len > 0 else { return } 1603 + text.withCString { ptr in 1578 1604 ghostty_surface_text(surface, ptr, UInt(len - 1)) 1579 1605 } 1606 + } 1607 + 1608 + @discardableResult 1609 + func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool { 1610 + let windowNumber = window?.windowNumber ?? 0 1611 + guard let keyDownEvent = key.keyDownEvent(windowNumber: windowNumber) else { 1612 + return false 1613 + } 1614 + keyDown(with: keyDownEvent) 1615 + if !key.isRepeat, 1616 + let keyUpEvent = key.keyUpEvent(windowNumber: windowNumber) 1617 + { 1618 + keyUp(with: keyUpEvent) 1619 + } 1620 + return true 1580 1621 } 1581 1622 1582 1623 @discardableResult
+126
supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift
··· 1 + import AppKit 2 + import Foundation 3 + 4 + struct MirroredTerminalKey: Equatable, Sendable { 5 + enum Kind: Equatable, Sendable { 6 + case enter 7 + case backspace 8 + case deleteForward 9 + case arrowUp 10 + case arrowDown 11 + case arrowLeft 12 + case arrowRight 13 + case tab 14 + case escape 15 + case controlCharacter 16 + } 17 + 18 + let kind: Kind 19 + let keyCode: UInt16 20 + let characters: String 21 + let charactersIgnoringModifiers: String 22 + /// Raw value of `NSEvent.ModifierFlags` (device-independent only) to satisfy `Sendable`. 23 + let modifierFlagsRawValue: UInt 24 + let isRepeat: Bool 25 + 26 + var modifiers: NSEvent.ModifierFlags { 27 + NSEvent.ModifierFlags(rawValue: modifierFlagsRawValue) 28 + } 29 + 30 + /// Key codes allowed to pass through even with the Command modifier held. 31 + private static let commandAllowedKeyCodes: Set<UInt16> = [ 32 + 51, // backspace 33 + 123, // arrowLeft 34 + 124, // arrowRight 35 + 125, // arrowDown 36 + 126, // arrowUp 37 + ] 38 + 39 + init?( 40 + kind: Kind, 41 + keyCode: UInt16, 42 + characters: String, 43 + charactersIgnoringModifiers: String, 44 + modifiers: NSEvent.ModifierFlags, 45 + isRepeat: Bool 46 + ) { 47 + if modifiers.contains(.command), !Self.commandAllowedKeyCodes.contains(keyCode) { return nil } 48 + self.kind = kind 49 + self.keyCode = keyCode 50 + self.characters = characters 51 + self.charactersIgnoringModifiers = charactersIgnoringModifiers 52 + modifierFlagsRawValue = modifiers.intersection(.deviceIndependentFlagsMask).rawValue 53 + self.isRepeat = isRepeat 54 + } 55 + 56 + init?(event: NSEvent) { 57 + guard event.type == .keyDown else { return nil } 58 + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) 59 + if modifiers.contains(.command), !Self.commandAllowedKeyCodes.contains(event.keyCode) { return nil } 60 + guard let kind = Self.kind(for: event, modifiers: modifiers) else { return nil } 61 + self.kind = kind 62 + keyCode = event.keyCode 63 + characters = event.characters ?? "" 64 + charactersIgnoringModifiers = event.charactersIgnoringModifiers ?? event.characters ?? "" 65 + modifierFlagsRawValue = modifiers.rawValue 66 + isRepeat = event.isARepeat 67 + } 68 + 69 + private static func kind( 70 + for event: NSEvent, 71 + modifiers: NSEvent.ModifierFlags 72 + ) -> Kind? { 73 + switch event.keyCode { 74 + case 36, 76: return .enter 75 + case 48: return .tab 76 + case 51: return .backspace 77 + case 117: return .deleteForward 78 + case 53: return .escape 79 + case 123: return .arrowLeft 80 + case 124: return .arrowRight 81 + case 125: return .arrowDown 82 + case 126: return .arrowUp 83 + default: 84 + break 85 + } 86 + 87 + if modifiers == [.control], 88 + let charactersIgnoringModifiers = event.charactersIgnoringModifiers, 89 + !charactersIgnoringModifiers.isEmpty 90 + { 91 + return .controlCharacter 92 + } 93 + 94 + return nil 95 + } 96 + 97 + func keyDownEvent(windowNumber: Int) -> NSEvent? { 98 + NSEvent.keyEvent( 99 + with: .keyDown, 100 + location: .zero, 101 + modifierFlags: modifiers, 102 + timestamp: ProcessInfo.processInfo.systemUptime, 103 + windowNumber: windowNumber, 104 + context: nil, 105 + characters: characters, 106 + charactersIgnoringModifiers: charactersIgnoringModifiers, 107 + isARepeat: isRepeat, 108 + keyCode: keyCode 109 + ) 110 + } 111 + 112 + func keyUpEvent(windowNumber: Int) -> NSEvent? { 113 + NSEvent.keyEvent( 114 + with: .keyUp, 115 + location: .zero, 116 + modifierFlags: modifiers, 117 + timestamp: ProcessInfo.processInfo.systemUptime, 118 + windowNumber: windowNumber, 119 + context: nil, 120 + characters: characters, 121 + charactersIgnoringModifiers: charactersIgnoringModifiers, 122 + isARepeat: false, 123 + keyCode: keyCode 124 + ) 125 + } 126 + }
+130
supacodeTests/CanvasSelectionStateTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct CanvasSelectionStateTests { 7 + private let tab1 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) 8 + private let tab2 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) 9 + private let tab3 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!) 10 + 11 + @Test func focusSingleSetsPrimaryAndClearsSelectionMode() { 12 + var state = CanvasSelectionState() 13 + 14 + state.focusSingle(tab1) 15 + 16 + #expect(state.mode == .idle) 17 + #expect(state.primaryTabID == tab1) 18 + #expect(state.selectedTabIDs == [tab1]) 19 + #expect(state.selectionOrder == [tab1]) 20 + } 21 + 22 + @Test func toggleSelectionEntersSelectionModeAndAppendsOrder() { 23 + var state = CanvasSelectionState() 24 + 25 + state.toggleSelection(tab1) 26 + state.toggleSelection(tab2) 27 + 28 + #expect(state.mode == .selecting) 29 + #expect(state.primaryTabID == tab2) 30 + #expect(state.selectedTabIDs == [tab1, tab2]) 31 + #expect(state.selectionOrder == [tab1, tab2]) 32 + } 33 + 34 + @Test func togglingSelectedPrimaryPromotesPreviousSelection() { 35 + var state = CanvasSelectionState() 36 + state.toggleSelection(tab1) 37 + state.toggleSelection(tab2) 38 + state.toggleSelection(tab3) 39 + 40 + state.toggleSelection(tab3) 41 + 42 + #expect(state.mode == .selecting) 43 + #expect(state.primaryTabID == tab2) 44 + #expect(state.selectedTabIDs == [tab1, tab2]) 45 + #expect(state.selectionOrder == [tab1, tab2]) 46 + } 47 + 48 + @Test func togglingLastSelectedCardClearsState() { 49 + var state = CanvasSelectionState() 50 + state.toggleSelection(tab1) 51 + 52 + state.toggleSelection(tab1) 53 + 54 + #expect(state.mode == .idle) 55 + #expect(state.primaryTabID == nil) 56 + #expect(state.selectedTabIDs.isEmpty) 57 + #expect(state.selectionOrder.isEmpty) 58 + } 59 + 60 + @Test func broadcastInteractionLeavesSelectionSetButExitsSelectionMode() { 61 + var state = CanvasSelectionState() 62 + state.toggleSelection(tab1) 63 + state.toggleSelection(tab2) 64 + 65 + state.beginBroadcastInteractionIfNeeded() 66 + 67 + #expect(state.mode == .idle) 68 + #expect(state.primaryTabID == tab2) 69 + #expect(state.selectedTabIDs == [tab1, tab2]) 70 + } 71 + 72 + @Test func setPrimaryPromotesFollowerWithoutClearingSelection() { 73 + var state = CanvasSelectionState() 74 + state.toggleSelection(tab1) 75 + state.toggleSelection(tab2) 76 + state.toggleSelection(tab3) 77 + state.beginBroadcastInteractionIfNeeded() 78 + 79 + state.setPrimary(tab1) 80 + 81 + #expect(state.primaryTabID == tab1) 82 + #expect(state.selectedTabIDs == [tab1, tab2, tab3]) 83 + #expect(state.selectionOrder == [tab2, tab3, tab1]) 84 + } 85 + 86 + @Test func setPrimaryIgnoresUnselectedTab() { 87 + var state = CanvasSelectionState() 88 + state.toggleSelection(tab1) 89 + state.toggleSelection(tab2) 90 + 91 + state.setPrimary(tab3) 92 + 93 + #expect(state.primaryTabID == tab2) 94 + #expect(state.selectedTabIDs == [tab1, tab2]) 95 + } 96 + 97 + @Test func selectAllSelectsEveryTabAndKeepsExistingPrimary() { 98 + var state = CanvasSelectionState() 99 + state.focusSingle(tab2) 100 + 101 + state.selectAll([tab1, tab2, tab3]) 102 + 103 + #expect(state.selectedTabIDs == [tab1, tab2, tab3]) 104 + #expect(state.primaryTabID == tab2) 105 + #expect(state.selectionOrder.last == tab2) 106 + #expect(state.mode == .idle) 107 + } 108 + 109 + @Test func selectAllFromEmptyPicksLastTab() { 110 + var state = CanvasSelectionState() 111 + 112 + state.selectAll([tab1, tab2, tab3]) 113 + 114 + #expect(state.selectedTabIDs == [tab1, tab2, tab3]) 115 + #expect(state.primaryTabID == tab3) 116 + } 117 + 118 + @Test func pruneDropsMissingTabsAndPreservesNewestVisiblePrimary() { 119 + var state = CanvasSelectionState() 120 + state.toggleSelection(tab1) 121 + state.toggleSelection(tab2) 122 + state.toggleSelection(tab3) 123 + 124 + state.prune(to: [tab1, tab2]) 125 + 126 + #expect(state.primaryTabID == tab2) 127 + #expect(state.selectedTabIDs == [tab1, tab2]) 128 + #expect(state.selectionOrder == [tab1, tab2]) 129 + } 130 + }
+121
supacodeTests/MirroredTerminalKeyTests.swift
··· 1 + import AppKit 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct MirroredTerminalKeyTests { 7 + @Test func enterEventNormalizes() throws { 8 + let event = NSEvent.keyEvent( 9 + with: .keyDown, 10 + location: .zero, 11 + modifierFlags: [], 12 + timestamp: 0, 13 + windowNumber: 0, 14 + context: nil, 15 + characters: "\r", 16 + charactersIgnoringModifiers: "\r", 17 + isARepeat: false, 18 + keyCode: 36 19 + ) 20 + 21 + let key = MirroredTerminalKey(event: try #require(event)) 22 + 23 + #expect(key?.kind == .enter) 24 + #expect(key?.keyCode == 36) 25 + } 26 + 27 + @Test func commandModifiedEventsAreFilteredOut() throws { 28 + let event = NSEvent.keyEvent( 29 + with: .keyDown, 30 + location: .zero, 31 + modifierFlags: [.command], 32 + timestamp: 0, 33 + windowNumber: 0, 34 + context: nil, 35 + characters: "c", 36 + charactersIgnoringModifiers: "c", 37 + isARepeat: false, 38 + keyCode: 8 39 + ) 40 + 41 + #expect(MirroredTerminalKey(event: try #require(event)) == nil) 42 + } 43 + 44 + @Test func controlCharacterEventNormalizes() throws { 45 + let event = NSEvent.keyEvent( 46 + with: .keyDown, 47 + location: .zero, 48 + modifierFlags: [.control], 49 + timestamp: 0, 50 + windowNumber: 0, 51 + context: nil, 52 + characters: "\u{03}", 53 + charactersIgnoringModifiers: "c", 54 + isARepeat: false, 55 + keyCode: 8 56 + ) 57 + 58 + let key = MirroredTerminalKey(event: try #require(event)) 59 + 60 + #expect(key?.kind == .controlCharacter) 61 + #expect(key?.charactersIgnoringModifiers == "c") 62 + #expect(key?.modifiers == [.control]) 63 + } 64 + 65 + @Test func commandBackspaceIsAllowed() throws { 66 + let event = NSEvent.keyEvent( 67 + with: .keyDown, 68 + location: .zero, 69 + modifierFlags: [.command], 70 + timestamp: 0, 71 + windowNumber: 0, 72 + context: nil, 73 + characters: "\u{7F}", 74 + charactersIgnoringModifiers: "\u{7F}", 75 + isARepeat: false, 76 + keyCode: 51 77 + ) 78 + 79 + let key = MirroredTerminalKey(event: try #require(event)) 80 + 81 + #expect(key?.kind == .backspace) 82 + #expect(key?.modifiers == [.command]) 83 + } 84 + 85 + @Test func commandArrowIsAllowed() throws { 86 + let event = NSEvent.keyEvent( 87 + with: .keyDown, 88 + location: .zero, 89 + modifierFlags: [.command], 90 + timestamp: 0, 91 + windowNumber: 0, 92 + context: nil, 93 + characters: "", 94 + charactersIgnoringModifiers: "", 95 + isARepeat: false, 96 + keyCode: 124 97 + ) 98 + 99 + let key = MirroredTerminalKey(event: try #require(event)) 100 + 101 + #expect(key?.kind == .arrowRight) 102 + #expect(key?.modifiers == [.command]) 103 + } 104 + 105 + @Test func plainTextEventDoesNotNormalizeAsMirroredKey() throws { 106 + let event = NSEvent.keyEvent( 107 + with: .keyDown, 108 + location: .zero, 109 + modifierFlags: [], 110 + timestamp: 0, 111 + windowNumber: 0, 112 + context: nil, 113 + characters: "a", 114 + charactersIgnoringModifiers: "a", 115 + isARepeat: false, 116 + keyCode: 0 117 + ) 118 + 119 + #expect(MirroredTerminalKey(event: try #require(event)) == nil) 120 + } 121 + }