Release v1.11.0#1
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rojection Closes v2 plan §4.1 (P0). Background workspaces no longer needed a manual selection switch to show updated titles emitted by agent output. Root cause: WorkspaceSidebarV3Card adopts `.equatable()` to protect the typing-latency hot path. The previous `==` did not include `tab.title`, `tab.customTitle`, `tab.isPinned`, or `tab.customColor`, so SwiftUI short-circuited the body re-evaluation when only those identity fields changed on a non-selected workspace. Fix: introduce a typed `SidebarTabSummary` projection on `TabManager` that subscribes (per-tab Combine sink) to those four identity fields. The sidebar `Card` now holds the summary and `==` compares it. Other 47 `@Published` fields on `Workspace` continue to bypass this channel, preserving the typing-latency protection that motivated the original `Equatable` conformance. New regression coverage: iccTests/BackgroundWorkspaceTitleFreshnessTests (6 cases) pins both layers — model dispatch through Notification.didSet and view equality on title/style fields. Performance impact: a tabs mutation triggers one O(n) observer reconcile; each per-tab subscription merges 4 publishers and writes a dictionary slot only when the summary actually changes. Typing path is untouched. References: - docs/v2_platform_excellence_plan.md §4.1 (root cause + verdict) - docs/v2_platform_excellence_plan.md §3.2 (TabSummary architecture)
After investigating §4.5 ("cmd+shift+H ring only flashes once") in the v2
plan, the implementation already meets the design intent on the four
panel-flash paths:
- MarkdownPanelView (Sources/Panels/MarkdownPanelView.swift:269-282)
uses a `focusFlashAnimationGeneration` counter so a re-trigger cancels
the in-flight DispatchQueue.main.asyncAfter segments via the generation
guard.
- BrowserPanelView (Sources/Panels/BrowserPanelView.swift:1204-1215)
follows the same pattern.
- TerminalPanel (GhosttyTerminalView.swift:8645-8662) drops the prior
CAKeyframeAnimation via `flashLayer.removeAllAnimations()` before
re-adding, so `triggerFlash` is naturally idempotent.
- FilePanel (FilePanel.swift:160) bumps the model token; the view layer
it feeds is currently outside the flashed-ring story (see OPEN ISSUE
in docs/superpowers/specs/2026-05-07-p0-sweep-plan.md §6).
So instead of touching the pattern, this change pins the contract.
PanelFocusFlashTokenTests asserts:
- two consecutive `triggerFlash(reason:)` calls strictly increment the
`@Published focusFlashToken` value (a future "guard token != lastToken"
refactor would re-introduce the §4.5 symptom; this catches that).
- a flash-disabled UserDefault short-circuits the bump.
- Combine subscribers (what SwiftUI's `.onChange(of: token)` observes
under the hood) receive every emission, not a deduplicated stream.
- `FocusFlashPattern.segments` stays non-empty so view layers always
have something to animate.
OPEN ISSUE: the original report mentioned a "cmd+shift+H" shortcut that
does not appear in `KeyboardShortcutSettings.Action`. Recorded in the
sweep plan §6 for user clarification before promoting any further work.
References:
- docs/v2_platform_excellence_plan.md §4.5
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W4 task card)
…dChangeAt Closes v2 plan §4.6 (P0). Manually marking a workspace's notifications unread via the sidebar context menu used to leave them in their original insertion position, so the user saw no visible signal of the action. Root cause: `TerminalNotificationStore` published items in insertion order (`updated.insert(notification, at: 0)` at TerminalNotificationStore.swift:1014). `markUnread(forTabId:)` only flipped `isRead` and never reordered. Fix: - `TerminalNotification` adds `lastReadChangeAt: Date` (defaults to `createdAt` so existing fixtures and the deliver-then-show path keep natural newest-first order). - A new total comparator `applyDisplayOrder(to:)` orders by `(isUnread DESC, lastReadChangeAt DESC, createdAt DESC)`. - Every isRead-flipping mutator now stamps `lastReadChangeAt = Date()` and re-applies the order before publishing: `markRead(id:)`, `markRead(forTabId:)`, `markRead(forTabId:surfaceId:)`, `markUnread(forTabId:)`, `markAllRead`. - Insertion-time `insert(at: 0)` is replaced with `append + applyDisplayOrder` so the contract is "every publish is sorted". - `replaceNotificationsForTesting` also runs the order so test fixtures do not produce a different order than production. Persistence: TerminalNotification is not Codable and the store does not hydrate from SessionPersistence; no migration path is required. Tests: TerminalNotificationSortTests pins the comparator semantics and each mutator's lastReadChangeAt advancement (5 cases). Pre-existing WorkspaceManualUnreadTests still passes (8/8 green). References: - docs/v2_platform_excellence_plan.md §4.6 - docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W5 task card)
After investigating §4.4 ("dropping a file shows file:// URL instead of
POSIX path") on the live source, both production paths already meet the
design intent:
- Drop path: FileDropOverlayView.onDrop (Sources/ContentView.swift:3501)
→ terminal.hostedView.handleDroppedURLs(_:) → handleDroppedFileURLs
→ TerminalImageTransferPlanner.plan(fileURLs:target:). For a `.local`
target the planner returns `.insertText(insertedText(for:))`, which
joins `escapeForShell($0.path)` per URL — POSIX-shell-safe POSIX path,
never `file://`.
- Paste path: GhosttyTerminalView.swift:101 already does
`isFileURL ? escapeForShell($0.path) : absoluteString` when synthesizing
the pasted string from a multi-URL pasteboard.
So this commit does not modify behavior. Instead it pins the contract:
TerminalDropPathPosixContractTests asserts via the public planner entry
that:
- single fileURL plans `.insertText` containing the bare POSIX path,
- a path with whitespace ends up either backslash-escaped or single-
quoted (never bare),
- multi-file drops join with a space and never carry the `file://` prefix,
- empty input plans `.reject`,
- GhosttyPasteboardHelper.escapeForShell preserves simple paths,
single-quotes strings with newlines, and escapes bare metacharacters.
A future refactor that drops `escapeForShell` on either path would
silently regress §4.4; these tests catch that.
OPEN ITEM (recorded in sweep plan §6): if the user observed an actual
`file://` URL appearing in the terminal, it likely came from a path that
this codebase doesn't service — e.g. dragging a Finder sidebar location
URL, or the dragging source posting only `public.url` (not `public.fileURL`)
to the pasteboard. Will be revisited if the user provides a repro.
References:
- docs/v2_platform_excellence_plan.md §4.4
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W3 task card)
…k state
After investigating §4.3 ("sidebar drag state stuck") the production
architecture already handles every terminal path:
- `SidebarDragFailsafeMonitor` (ContentView.swift:12783-12903) observes
NSApplication.didResignActiveNotification, escape key, local + global
leftMouseUp, plus a CGEventSource mouse-button poll at 200ms cadence.
- `SidebarDragLifecycleNotification` fan-out + `.onReceive` subscription
at ContentView.swift:12228-12235 resets `draggedTabId` on any clear.
- `SidebarDragFailsafePolicy.shouldRequestClear*` are pure predicates
already pinned by `SidebarDragFailsafePolicyTests` in
`iccTests/SessionPersistenceTests.swift:1532-1579`.
No behavior change. This commit pins the two adjacent contracts that
previously had no unit coverage:
1. `SidebarOutsideDropResetPolicy.shouldResetDrag` — only an active
sidebar drag with a sidebar-type pasteboard payload triggers a reset.
Catches regressions where a foreign payload (e.g. file URL drag)
would steal the sidebar reset path.
2. `SidebarDragLifecycleNotification` wire format — `tabId` and `reason`
userInfo keys plus the "unknown" fallback. Catches regressions where
a rename/refactor would break silent subscribers.
5 new cases under `SidebarDragLifecycleContractTests`. A live UI test
(`iccUITests/SidebarDragCancelUITests.swift`) remains on the §4.3 OPEN
ITEM list for future work — this commit does not block on it.
References:
- docs/v2_platform_excellence_plan.md §4.3
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W2 task card)
…ership
Step 1 toward v2 plan §4.2 ("browser-to-terminal arrow-key recovery").
When a webview yields its first-responder slot via
`keyWindow.makeFirstResponder(nil)` — which happens during inspector
preflight, panel close, and a few other paths — AppKit leaves the
responder chain at the window level and key events are silently dropped.
The user reports "arrow keys stop working after switching back from
browser to terminal."
The plan calls for a small stack-based focus tracker. This commit lands
the data structure and the producer side; the consumer hookup
(post-`makeFirstResponder(nil)` fallback) requires a surfaceId →
NSResponder lookup that lives in `AppDelegate` and would expand the
allow-list, so it is deferred to a follow-up. Tracked as the §4.2 OPEN
ITEM in `docs/superpowers/specs/2026-05-07-p0-sweep-plan.md`. With the
producer wired and the contract pinned, the follow-up is a clean diff
("subscribe to top()") rather than design + implement + test.
Producer side:
- `Sources/UI/Focus/FocusStack.swift` (new): main-actor singleton with
`push` (idempotent on top, capped at 16, oldest dropped on overflow),
`popIfTop` (only-if-top so out-of-order calls do not perturb the
stack), `top()`, `clear()`, and `depth`.
- `Sources/Panels/IccWebView.swift` `becomeFirstResponder()` pushes a
`.browser(webViewId:)` frame on success.
- `Sources/Panels/IccWebView.swift` `resignFirstResponder()` pops only
when the top still matches (defensive against interleaved focus
changes).
Tests: FocusStackTests pins push/pop/dedup/overflow/equality semantics.
A subtle hazard found in test development is documented in the test
helper: `ObjectIdentifier(NSObject())` of two back-to-back temporaries
can collide once ARC reuses the slot, so the test holds the sentinel
NSObjects with explicit lifetime.
Performance: push/pop are O(1) on a small array; the 16-cap means the
worst-case overflow trim is a single `removeFirst(1)`. Not on any hot
path — only triggered on first-responder transitions.
References:
- docs/v2_platform_excellence_plan.md §4.2
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W1 task card)
…ld falls back
Step 2 of v2 plan §4.2 ("browser-to-terminal arrow-key recovery"). The
prior commit (34b1f21) wired the producer side on IccWebView and left
two follow-ups: terminal-surface push/pop, and a consumer hook so the
fallback actually runs after the webView lets go of first responder.
Producer completion (GhosttyTerminalView.swift):
- `becomeFirstResponder()` now pushes `.terminal(surfaceId:)` onto
`FocusStack.shared` when the surfaceId is known.
- `resignFirstResponder()` calls `popIfTop` symmetrically. As with the
IccWebView pair, the only-if-top guard means interleaved focus changes
do not perturb the stack.
Consumer hook (BrowserPanel.swift `yieldFocusIntent` `.webView`):
- After `window.makeFirstResponder(nil)` succeeds, peek at
`FocusStack.shared.top()`. If it is `.terminal(surfaceId:)`, look up
the matching `GhosttyNSView` via the new
`AppDelegate.focusableTerminalView(forSurfaceId:in:)` reverse lookup
and call `window.makeFirstResponder(target)`. Same window only — a
webView in window A must never re-anchor focus on a terminal in
window B.
- The lookup walks `tabManager.tabs[*].panels.values`, casts each to
`TerminalPanel`, matches on `id == surfaceId` (TerminalPanel.id is
`surface.id`), and gates by `view.window === window`. n is small
(number of workspaces × panels per workspace, both bounded), so the
linear scan is fine; no need for a global surfaceId index.
Tests: FocusStackConsumerHookTests pins the lookup contract at the
narrowest gates that don't require a full window/key-cycle harness:
- unknown surfaceId → nil
- nil tabManager → nil (no crash)
- surface in different window → nil (cross-window safety)
The "happy path: surface attached to the queried window → returns the
hosted view" runs in production via BrowserPanel; the integration
coverage path remains a §4.2 OPEN ITEM in the sweep plan. The producer
side is exercised by the existing FocusStackTests (10 cases).
Performance: producer push/pop are O(1) on a 16-cap array; consumer
lookup is O(workspaces × panels-per-workspace) on focus transitions
(not on a hot path). No new allocations on the typing or render path.
References:
- docs/v2_platform_excellence_plan.md §4.2
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W1 task card)
The v2 platform-excellence plan §7 S0 calls out "freeze growth" as the
first move before any structural refactor: keep the existing oversized
files (`ContentView.swift` 24,514 lines, `TerminalController.swift`
15,618, `AppDelegate.swift` 13,722, etc.) from getting bigger, so the
S1–S5 decomposition steps can land on a stable target.
This commit lands the guard:
- `scripts/file-size-baseline.txt`: snapshot of every Swift source/test
file ≥ 1,500 lines as of branch `fix/sidebar-title-freshness`. 28
entries. Format is `<path> <line_count>` per line; `#` is comment.
Re-baselining is a deliberate commit (see `--regenerate` below).
- `scripts/check-file-sizes.sh`: pure-shell guard. Default mode reads
the baseline, recounts each file, and fails (exit 1) on growth past
`max(+5%, +50 lines)`. Files that have shrunk past 5% trigger a
non-fatal hint pointing the maintainer at "the baseline can be
tightened." `--regenerate` rewrites the baseline preserving the
header comments — intended as a reviewed commit when an offender
legitimately needs to grow.
Carmack-mode design notes:
- O(n) over the baseline; n is small (28). Pure shell, no `bc`/`awk`
beyond `awk '{print $1 $2}'`-style splitting; no `jq`/`python`.
- Tolerance picks `max(+5%, +50)` so a 1,500-line file gets a 75-line
budget but a 24,000-line file gets 1,225. Small files don't get
stuck on rounding; big files don't get a free pass.
- Shrink-below-5% surfaces as a hint, not a failure: the goal is to
shrink these files, so we never want the guard to punish progress.
Validation performed (all manual, since this is itself test infra):
- baseline equal to current state ⇒ 0 failures, 0 hints (exit 0).
- forced over-budget value (Sources/ContentView.swift baseline=100) ⇒
FAIL, exit 1, ceiling reported.
- forced over-baseline value (Sources/TerminalNotificationStore.swift
baseline=2000 vs 1570 actual) ⇒ shrink hint, exit 0.
- `--regenerate` after corrupting one row to 9999 restores the row to
the true 1,570 and preserves the comment header.
Not bundled in this commit (deliberate scope guard): a CI workflow that
runs `./scripts/check-file-sizes.sh`. CI workflow files are shared
infrastructure and are left for a follow-up review with the user.
References:
- docs/v2_platform_excellence_plan.md §7 S0 "证据基线"
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (next step after P0)
Second of three S0 baseline tools (after `check-file-sizes.sh`). The
v2 plan §2.2 / §2.3 / §2.5 diagnose the SwiftUI typing-latency churn
as caused by:
1. Heavy `@Published` counts on big model objects. Adding one more
`@Published var` to `Workspace.swift` re-dirties the entire
workspace UI tree on every mutation; the §4.1 `SidebarTabSummary`
projection landed exactly to bypass this for the sidebar path.
2. NotificationCenter `addObserver` / `removeObserver` imbalance.
A growing diff is a dangling-observer smell.
Captured 2026-05-07 baseline:
Sources/Workspace.swift 62 @published ← plan §2.3 cited 51; drifted +11
Sources/Panels/BrowserPanel.swift 25
Sources/TabManager.swift 11
Sources/SidebarExplorers.swift 8
Sources/Panels/TerminalPanel.swift 5
Sources/Panels/FilePanel.swift 5
Sources/GhosttyTerminalView.swift 5
Sources/WorkspaceContentView.swift 4
Sources/Update/UpdateViewModel.swift 4
Sources/Panels/MarkdownPanel.swift 4
Sources/iccApp.swift 4
Sources/TerminalNotificationStore.swift 3
Sources/ContentView.swift 3
Sources/Update/UpdateTitlebarAccessory.swift 1
Sources/SidebarSelectionState.swift 1
notify_balance addObserver=107 removeObserver=56 (diff=51)
Tolerance:
- per-file `@Published`: actual ≤ baseline ⇒ OK; baseline+1..+2 ⇒
informational warn (still exit 0); > baseline+2 ⇒ FAIL exit 1.
`>10%` shrink emits a hint suggesting the baseline can be tightened.
- notify_balance: current diff (add - remove) > baseline diff ⇒ FAIL.
Smaller diff is good and emits a hint.
The +2 slack on `@Published` keeps a one-off legitimate observable
addition from blocking PRs; bigger growth must be a deliberate,
reviewed `--regenerate` commit. The intent is the same as the v2 plan
§7 S0 "freeze growth" doctrine: the baseline only loosens by an
explicit, justified act, never by silent drift.
Validation performed (manual; this is itself test infra):
- baseline = current ⇒ 0/0/0 (exit 0) ✓
- forced bigger gap (Workspace.swift baseline=50, actual=62, +12) ⇒
FAIL exit 1 ✓
- within-slack drift (baseline=60, actual=62, +2) ⇒ warn exit 0 ✓
- shrink (baseline=70, actual=62, -8) ⇒ hint exit 0 ✓
- notify_balance widening (baseline diff=44, current 51) ⇒ FAIL ✓
- `--regenerate` after corrupting Workspace row to 9999 restored 62
and preserved the comment header ✓
Not bundled here (deliberate scope guard):
- A CI workflow that runs both `check-file-sizes.sh` and this script.
- The third S0 piece, `Telemetry/HotPathSampler.swift`, which needs
Swift-level instrumentation and merits its own commit.
References:
- docs/v2_platform_excellence_plan.md §2.2, §2.3, §2.5, §7 S0
Two small documentation updates to close the S0 thread: 1. `docs/v2_platform_excellence_plan.md` §1 evidence-snapshot table: the Workspace `@Published` count is annotated with the current drift (51 when the plan was written 2026-04-30, now 62 at 2026-05-07) and links to `scripts/observable-baseline.txt` so a future reader sees immediately that the contract has already been silently relaxed by +11 and the guard is the thing that stops further drift. 2. `docs/dev/freeze-growth-baselines.md` (new): developer-facing explanation of the two S0 guard scripts (`check-file-sizes.sh`, `audit-observables.sh`), tolerance policy, regeneration etiquette, and what's intentionally not-yet-bundled (`Telemetry/HotPathSampler.swift`, CI workflow integration). No code changes. Zero regression surface. References: - docs/v2_platform_excellence_plan.md §1, §7 S0
…rf budgets Third and final v2 plan §7 S0 baseline tool. The plan §5 sets P95 budgets the codebase has no way to measure today (typing latency, socket non-focus / focus latency, session restore, snapshot size). `performOnMainSync` already emits an NSLog past 100ms (`Sources/TerminalController.swift:2953`), but those events cannot be aggregated, queried for P95, or asserted against in CI. This commit lands the read-side primitive: `Sources/Telemetry/HotPathSampler.swift` - `record(metric:elapsedMs:)` aggregates count + sum + min + max + a 256-slot reservoir under an `os_unfair_lock`. Hot-path cost is one index write plus a few integer adds. - `snapshot(metric:)` / `snapshotAll()` returns immutable views; an approximate percentile (nearest-rank with linear interp) is computed from the reservoir on demand. - `measure(metric:_:)` wraps a synchronous block and records its elapsed time, threading the result back to the caller. - `isEnabled` defaults false. The plan §7 S0 explicitly schedules a collect-only phase before any CI gate, so the sampler ships dark. - `HotPathMetric` enum collects the stable metric names the plan §5 budgets refer to (`typing.latency`, `socket.nonFocus`, etc.) so the call sites stay greppable and typo-resistant. Carmack-mode design notes: - No heap growth on the hot path. Reservoir is fixed-capacity circular; older samples are overwritten in place. - Disabled-sampler cost is a single bool check at the top of `record`. Same for the `measure` wrapper. - String-keyed map intentionally — the metric set is small and bounded, and a string key keeps the call sites readable. If the set ever turns dynamic, this becomes a hash-stable enum. Wiring to live hot paths (forceRefresh, performOnMainSync, socket dispatch) is intentionally NOT in this commit. The plan §7 S0 says "land collection first, then turn on gates." Hooking the keystroke path also needs a measurement of "type to pixel" that is not reachable without GPU frame-flush hooks; that follow-up will need its own design pass. Tests (HotPathSamplerTests): - Disabled sampler is a no-op (no retained samples). - count / sum / min / max / mean compute correctly. - Negative, NaN, and infinite inputs are rejected. - Reservoir caps at `reservoirCapacity` with FIFO eviction. - Percentile from reservoir for known distribution (1...100). - Multiple metrics stay isolated; `snapshotAll()` returns sorted keys. - `measure` wraps a block and threads result + records elapsed time. - `reset` clears all state. 8/8 green. Combined with the existing P0 sweep and FocusStack tests: 56 tests across the new infra, 0 failures. References: - docs/v2_platform_excellence_plan.md §5 (perf budgets), §7 S0 - docs/dev/freeze-growth-baselines.md (now lists all three S0 tools)
Moves the sampler from 'not-yet-bundled' to a dedicated section that documents its role, the `HotPathMetric` name registry, the coverage in `HotPathSamplerTests`, and the `isEnabled` collect-only stance. The new 'not-yet-bundled' list now correctly lists the two real remaining pieces: sampler wiring to live call sites, and a CI workflow that runs the guards + exports the sampler's P95.
Bundles the v2 platform-excellence §4 P0 sweep and §7 S0 baseline tools: - §4.1 sidebar title freshness: SidebarTabSummary projection - §4.5 ring-flash token contract pinned - §4.6 notification sort uses lastReadChangeAt - §4.4 terminal drop POSIX path contract pinned - §4.3 sidebar drag lifecycle contract pinned - §4.2 FocusStack producer + consumer fallback for browser → terminal - §7 S0: check-file-sizes.sh + audit-observables.sh + HotPathSampler 12 feature/fix commits, 56 new tests (0 failures), 2 guard scripts (0 violations).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- web/app/site-config.ts: bump version reference to v1.10.20 (146) with the new SHA256 / size for the freshly notarized DMG. - web/app/marketing-home.tsx: rewrite the release banner panel for zh-CN and EN to lead with the v2 platform-excellence first wave (background title freshness, focus recovery, unread sort, file- size + observable-graph baselines). The four sanity-check bullets call out what users should see immediately after the upgrade. - CHANGELOG.md: add the [1.10.20] section with Added / Fixed / Changed groupings keyed to v2 plan §4 / §7 S0. - docs/USAGE.md: bilingual (Chinese-first) end-user usage guide covering install, workspace mental model, splits, focus ring, remote SSH, browser pane, notifications, source control, supervisor, command palette, the new performance baselines, and the bundled icc CLI. The remaining 17 of 19 marketing-copy.ts locales are intentionally unchanged in this commit — they fall back to existing copy until a proper translation pass lands. The release banner above is the user-facing surface that needs to be current; the long-form hero/cards stay as they were so we don't ship half-translated copy.
- web/app/[locale]/posthog.tsx: `r.icc.com` resolved to a Pressable IP whose TLS certificate is for `pressable.com`, producing 9 console errors per page load (cert CN mismatch). Switched the api_host to PostHog's official `https://us.i.posthog.com` until a self-hosted proxy is properly TLS-terminated. Open DevTools on the homepage now shows 0 errors instead of red. - web/app/marketing-home.tsx: lifted the visual authority of the hero region without tearing up the layout: * Added a single trust-badge row directly under the CTA buttons: 'Apple Notarized', 'Sparkle Auto-Update', '56 Tests Passing', 'macOS 14+'. Bilingual (zh-CN labels for Chinese locales). Each badge is a tiny SVG checkmark + label, no images, no extra HTTP. These are exactly the four signals a first-time visitor uses to judge whether this is a real product before scrolling. * Re-styled the four release-panel sanity-check items as an emerald checklist (border-emerald-500/20, ✓ icon) instead of the previous flat grey cards. The four items already exist as bilingual copy; only the visual layer changed. No layout / structure changes elsewhere; the sticky orb panel, hero cards, and right-column terminal preview stay as they were.
Investigated further on 2026-05-08: - DNS for `r.icc.com` now resolves to Pressable shared hosting IPs. - TLS certificate served at that endpoint has CN=pressable.com — not a valid match for r.icc.com, so the browser was emitting ~9 console errors per page load. - The PostHog `/decide` endpoint at r.icc.com returns 'Domain not found', confirming the proxy is not actually serving any PostHog traffic to the project. So the previous configuration was producing noise without producing analytics. The api_host stays at `us.i.posthog.com` (already deployed in 69523e6). This commit is a comments-only update so future readers of the file are not misled into thinking r.icc.com is a working proxy that just needs a cert renewal — they will instead see the option of standing up a project-controlled proxy on `r.iccjk.com` if China-side latency becomes a real concern. No runtime behavior change. No redeploy needed.
Plan §7 S1 budgets 3 weeks for the control-plane reorg. Slicing it into 8 mergeable commits so we can ship continuously and roll back at any boundary. This commit is step 1: the design spec only — no source code is moved yet. Measured current state (2026-05-08): - TerminalController.swift 15,618 lines (plan baseline 14,384, +1,234) - v1 socket cases 228 (plan baseline 222) - v2 socket cases 98 (plan wrote 186; gap is dead-code removal) - performOnMainSync callers 174 in this single file - processV2Command 437 lines, processCommand 356 lines Plan §7 S1 acceptance carried into the spec verbatim: v2 non-focus P95 drops ≥ 15% from baseline; tests_v2 alignment stays green; TerminalController shrinks to ≤ 6,000 lines. Migration order: step 2 Extract MainActorHop step 3 Land Control/Envelope + Router/CommandRegistry step 4 Migrate system.* (4 cases) + manifest snapshot test step 5 Migrate window/workspace/notification (~25 cases) step 6 v1 cases become thin adapters into v2 handlers step 7 Verify TerminalController.swift ≤ 6,000 + acceptance suite References: - docs/v2_platform_excellence_plan.md §2.1, §3.3, §7 S1 - docs/superpowers/specs/2026-05-08-controlplane-v2-design.md
v2 plan §7 S1 step 2: factor the socket-thread → main-thread trampoline out of TerminalController so all hops route through one instrumented path. Plan §3.3 + §7 S1 acceptance. Sources/Control/Router/MainActorHop.swift (new): - Single `shared` instance + per-hop instrumentation hook into HotPathSampler under HotPathMetric.mainThreadHop. - Preserves the previous semantics exactly: main-thread short-circuit, DispatchQueue.main.sync trampoline, 100ms NSLog warning. The threshold is a configurable property so tests / DEBUG builds can tighten it without editing call sites. - The sampler hook is async-dispatched to main and is gated on `hop.sampler != nil` so the socket-thread hot path stays allocation-free when telemetry is disabled (default). Sources/TerminalController.swift (touched): - The private `performOnMainSync` helper is now a 7-line proxy that delegates to MainActorHop.shared.sync. The 174 call sites in this file keep working without source changes — same signature, same semantics, but every hop now flows through the new primitive. iccTests/MainActorHopTests.swift (new): - 4 cases: main-thread short-circuit, background trampoline + telemetry recording, no-sampler no-op, configurable warn threshold. - Uses `@MainActor` test class because HotPathSampler is main-actor- confined (its `isEnabled` and `reset` mutate isolated state). 71 sweep tests + 28 file-size baselines + 15 observable baselines all green. TerminalController.swift dropped by 3 lines (12-line helper → 8-line proxy). Real reduction from this work happens in step 7 once the new primitive can replace the file-private helper entirely; this step preserves backward compatibility while opening the migration path. Next step (step 3): land Sources/Control/Envelope/ + CommandRegistry skeleton so handlers can register without touching TerminalController. References: - docs/v2_platform_excellence_plan.md §3.3 (perf hop budget), §7 S1 - docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §3.4
v2 plan §7 S1 step 3: the typed control-plane envelope + registry that
v2 handlers will target. No socket traffic routes through the new
primitive yet — that wiring lands in step 4 alongside the first
namespace migration (system.*).
Sources/Control/Envelope/ControlEnvelope.swift (new):
- `ControlRequest` (id / method / params). Any? on id preserves the
legacy wire format where id can be string / int / null.
- `ControlError` enum with stable rawValues; these are the strings
external clients pattern-match. Adding a new code is a wire break.
- `ControlResult` = .ok(Any) / .err(code:message:data:). Shape-
identical to the file-private V2CallResult in TerminalController.
Sources/Control/Envelope/ControlEncoder.swift (new):
- `encode`, `ok`, `error`, `response`, `orNull`. Byte-for-byte
identical output to the legacy v2Encode/v2Ok/v2Error/v2OrNull
helpers; line-oriented socket framing is preserved by escaping
embedded newlines.
Sources/Control/Router/CommandRegistry.swift (new):
- `CommandRegistry` with register / handler / dispatch / methods /
reset. Method name is the fully-qualified dot-path (e.g.
'workspace.remote.configure'); matches do not pre-split.
- `ControlContext` carries tabManager (weak) and mainHop so handlers
do not each re-thread their own argument list.
- Unknown methods dispatch to .err(.methodNotFound, ...) so callers
never need a special-case.
All these types are `internal` (not `public`). TabManager is
internal, so ControlContext cannot be wider. The Router namespace is
intentionally closed within the app target today; if it ever needs to
be embedded elsewhere we will widen together.
iccTests/ControlEnvelopeTests.swift (new):
- 12 cases:
* ok / error shapes (id types, embedded newlines, fallback on bad
JSON, optional data, response wrapper).
* ControlError rawValues pinned to the legacy wire strings.
* Registry happy path, unknown-method path, sorted `methods()`,
`reset`.
This step is wire-compatible: the socket protocol does not change, no
handlers are migrated yet. Step 4 will register the first 4 handlers
(system.*) and land CommandManifestSnapshotTests.
16 sweep-adjacent tests green (12 new + 4 MainActorHop), 28 file-size
baselines and 15 observable baselines 0 violations.
References:
- docs/v2_platform_excellence_plan.md §7 S1 step 3
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §3.1–§3.3
v2 plan §7 S1 step 4: first namespace migration. Lands the
production-path wiring of CommandRegistry inside TerminalController
without changing wire-format behavior for any client.
What moved:
- system.ping is now registered into CommandRegistry.shared by
SystemHandlers.register(into:). The legacy 'case "system.ping":'
arm in processV2Command remains as a safety net (registry-not-
registered fallback). After step 7 the legacy arm gets deleted.
What did NOT move (by design):
- system.capabilities, system.identify, system.tree call into private
helpers (v2Capabilities, v2Identify, v2SystemTree) that still own
TerminalController state (v2Ref bookkeeping, tabManager.tabs,
AppDelegate window indexing). De-coupling those helpers is its own
step; they stay in the legacy switch until then.
How dispatch works now:
processV2Command(json) →
parse →
performOnMainSync { v2RefreshKnownRefs() } →
withSocketCommandPolicy { method →
if registry has handler(method): registry.dispatch → ControlEncoder.response ← new path
else: switch method { ... legacy arms ... }
}
So a client calling 'system.ping' goes through the new registry path
and receives byte-identical wire output as before. A client calling
'system.tree' continues to flow through the legacy switch, untouched.
Sources/Control/Handlers/SystemHandlers.swift (new):
- One `register(into:)` function. Returns the names registered so
the snapshot test can assert against the live API rather than a
separate hardcoded list.
iccTests/CommandManifestSnapshotTests.swift (new):
- Pins the registered method set to a single hand-written array
(`expectedRegisteredMethods`). When step 5/6 add namespaces the
array grows; tests force a deliberate snapshot diff in code review,
so a typo in a register call shows up immediately.
- 5 cases: SystemHandlers.register returns the names, manifest
matches, system.ping dispatches via registry, wire output matches
legacy shape, unmigrated method (system.tree) returns
.methodNotFound through the registry (which is what
processV2Command uses to decide fall-through to the legacy switch).
40 sweep-adjacent tests green, including TerminalControllerSocket-
SecurityTests (11) which exercises the v2 socket protocol end-to-end.
file-size and observable baselines 0 violations. TerminalController.
swift grew by 19 lines (the new registry-first dispatch block); will
shrink past baseline once steps 5–7 remove the legacy switch arms.
References:
- docs/v2_platform_excellence_plan.md §7 S1 step 4
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §4
…1 step 5)
- Add WindowHandlers (5 methods), WorkspaceHandlers (17 methods),
NotificationHandlers (5 methods) under Sources/Control/Handlers/.
All bridge to TerminalController.registryBridge so per-method internal
state stays private; the handler files are thin shims.
- Extend ControlResult with .errRaw(code: String, message:, data:) so
legacy raw error codes ("protected", "remote_error", "forbidden"
variants) round-trip the registry without being clobbered to
"internal_error". ControlEncoder gains errorRaw(); response()
switches across all three result cases.
- TerminalController.toControlResult prefers ControlError(rawValue:)
when the legacy code maps to a typed enum case, otherwise falls back
to .errRaw to preserve the wire string.
- CommandManifestSnapshotTests.expectedRegisteredMethods grows to 31
alphabetically-sorted methods covering all four namespaces. Adds
per-namespace register checks plus an end-to-end manifest snapshot
comparing the full set after all handlers register.
- start() now registers System/Window/Workspace/Notification handlers;
processV2Command consults the registry first and falls through to the
legacy switch when registry returns nil (gates remain in place
alongside the new path).
Tests: CommandManifestSnapshotTests, ControlEnvelopeTests,
MainActorHopTests, TerminalControllerSocketSecurityTests all green
(26/26 in targeted run; standalone re-run of security tests 11/11).
…1 step 6) v2Encode/v2Ok/v2Error/v2OrNull in TerminalController.swift are now thin adapters that forward to ControlEncoder. The duplicated JSON serialization path is gone; exactly one implementation now owns the line-oriented wire format. The legacy function signatures are preserved because ~80 callers still spell them out; renaming is a separate cleanup that would churn the diff without changing behavior. Tests: 36/36 green (CommandManifestSnapshot/ControlEnvelope/ MainActorHop/TerminalControllerSocketSecurity).
…ep 7) S1 lands the control-plane skeleton: - Sources/Control/Envelope/ControlEnvelope.swift (78 LOC) - Sources/Control/Envelope/ControlEncoder.swift (92 LOC) - Sources/Control/Router/CommandRegistry.swift (135 LOC) - Sources/Control/Router/MainActorHop.swift (94 LOC) - 4 handler files registering 31 methods through the registry Acceptance vs plan §7 S1: - Skeleton in place: DONE. - system/window/workspace/notification migration: DONE (31 methods, manifest-pinned). - v1 cases as thin v2 adapters: PARTIAL — wire encoder unified, but the per-method routing table is deferred to S2 (each v2Workspace* body still references private TabManager state that has to move into ControlContext first; doing the move requires a mechanical pass that didn't fit S1's 3-week budget without compromising the test gate). - CommandManifestSnapshotTests: DONE (9 tests, 31-method baseline). - performOnMainSync → MainActorHop: DONE (single trampoline path, HotPathSampler integration in place but disabled by default). - TerminalController.swift ≤ 6,000 lines: DEFERRED to S2 — current 15,730 (vs 15,618 baseline). The +112 LOC is the registryBridge switch added in step 5 that lets handlers stay external while internal state stays private. Net file-size baseline guard (28- file freeze) still 0 violations. Tests targeted to this work all green (51/52 — 1 pre-existing failure unrelated to control-plane in SocketControlPasswordStore). File-size + observable-graph guards green.
…forwarding
Crash report (1.10.20, 2.5h after launch, mid-drag):
Exception Subtype: KERN_PROTECTION_FAILURE at 0x16a76be10
'Could not determine thread index for stack guard region'
Triggered: com.apple.main-thread, otherMouseDragged path
Stack: ELIDED 87,010 LEVELS OF RECURSION through
AppKit forwardMethod + 252 → _nextResponderForEvent:
Root cause:
FileDropOverlayView caches the mouseDown target across drag events so
the original target keeps receiving dragged/up events. The cache is a
weak NSView, gated only by 'window != nil'. Portals (Terminal +
Browser) detach hostViews mid-drag during workspace/split rebuilds —
the cached view stays alive (still has window) but its superview
chain no longer reaches contentView. AppKit, asked to find the next
responder for the dragged event, walks a corrupt chain whose
nextResponder edges lead back into the overlay; 87k frames later the
main-thread stack overflows.
Fix:
1. isForwardedTargetStillLive walks the cached view's superview chain
and only honors the cache when the chain reaches contentView. A
detached portal hostView is treated as dead, so the next event
re-hitTests fresh.
2. responderChainContainsSelf bounds the nextResponder walk; any
self-edge or cycle (\u003e256 hops) is treated as a loop and the
forward is refused. This is the defense in depth so even a
newly-hitTested target can't loop us.
3. viewWillMove(toWindow: nil) drops the drag-target cache before
AppKit starts tearing down child views, removing the window
teardown variant of the same bug.
4. Both helpers are 'static internal' so the regression suite can
exercise them directly.
Tests: 6/6 in iccTests.FileDropOverlayResponderRecursionTests covering
both liveness and chain-cycle invariants.
… resume-on-reopen Problem: Closing an imux pane and reopening it starts claude fresh — the prior conversation is gone even though ~/.claude/projects/ still has the full transcript. VS Code's agent panel doesn't have this UX gap because it owns the chat history in-process; our wrapper execs the upstream CLI each time and the session-id was a fresh uuidgen per launch. Fix: 1. imux-agent-common.sh gains a session-id persistence layer keyed by (agent, ICC_WORKSPACE_ID, ICC_SURFACE_ID). The on-disk format is one file per (workspace, surface) under ~/Library/Application Support/icc/agent-sessions/<agent>/<ws>/<sf>.session, holding a single UUID. ICC_AGENT_SESSION_HOME overrides the base for tests. 2. imux_claude_session_has_transcript() verifies the id's .jsonl actually exists under ~/.claude/projects/ before we --resume it; a wiped project dir or stale id falls back to --session-id and a fresh uuid instead of hanging claude on a missing resume target. 3. Resources/bin/claude now: loads the prior id, verifies its transcript, and either 'exec claude --resume <id>' (replay conversation) or 'exec claude --session-id <uuid>' (new session, persisting the id for next launch). 4. tests/test_agent_session_id_persistence.sh pins the whole surface (path determinism, UUID validation, workspace/surface isolation, transcript-existence gate) with 10 assertions. The app side already injects ICC_WORKSPACE_ID + ICC_SURFACE_ID into each PTY (GhosttyTerminalView.swift:3684), so no Swift change was needed — this is a wrapper-only landing.
…ersistence)
Codex CLI lacks claude's --session-id 'fresh launch with this id'
flag, so the wrapper takes a different shape but provides the same
guarantee: closing+reopening an imux pane resumes the prior
conversation.
Strategy:
1. On launch in an imux pane (workspace+surface env present, no
subcommand): if a stored id exists AND its rollout is still on
disk under ~/.codex/sessions/, exec 'codex resume <id>'. The
resume path keeps the same id, so no post-launch capture needed.
2. Otherwise: run codex normally as a child, set an EXIT trap, and
on exit scan ~/.codex/sessions/ for the rollout written after our
launch marker. Extract its UUID from the
rollout-<timestamp>-<UUID>.jsonl filename and store it for the
next launch.
3. Subcommands (exec, login, mcp, resume, fork, ...) bypass the
resume layer entirely so we don't double-resume or re-enter our
own logic recursively.
Helpers added to imux-agent-common.sh:
- imux_codex_session_has_transcript() — "*<UUID>.jsonl" find under
sessions dir, early-quit on first hit.
- imux_codex_uuid_from_filename() — awk extracts the trailing 5
dash-separated segments (8-4-4-4-12 UUID), validates the shape.
- imux_codex_latest_session_id_since() — find -newer + stat sort
picks the newest rollout written after a marker file. Bounded at
32 candidates to keep capture cheap.
Override knobs:
- ICC_CODEX_RESUME_DISABLED=1 — escape hatch for users who want
pre-imux behavior.
- CODEX_SESSIONS_DIR — test override for the sessions dir.
Tests: 18/18 in tests/test_agent_session_id_persistence.sh, including
8 new codex-specific assertions covering UUID extraction (happy path
+ malformed name + non-rollout name), transcript existence, and the
post-launch latest-since capture (newest pick + marker boundary).
…laude mapping
Edge case: if a user manually runs 'claude --resume <id>' or
'claude --session-id <uuid>' inside an imux pane, our wrapper used
to SKIP_SESSION_ID and exec upstream claude without recording the
user's chosen id. The next imux relaunch of that pane would then
fall back to whichever older id we had cached — silently losing the
conversation the user just continued.
Fix:
1. Extract two helpers into imux-agent-common.sh so the wrapper's
arg parsing is testable:
- imux_argv_has_session_directive: does argv reference
--resume/--session-id/--continue/-c/-r?
- imux_extract_user_session_id: emit the UUID the user pinned
(separated form, equals form, or bare — bare returns empty
because we can't know what the picker will select).
2. When SKIP_SESSION_ID fires and we extracted a concrete UUID,
persist it under the current (workspace, surface) so the next
imux relaunch honors the user's choice.
3. Extended tests/test_agent_session_id_persistence.sh from 18 → 31
assertions covering directive detection (bare flags, short form,
unrelated args) and UUID extraction (separated, equals, bare,
malformed, irrelevant).
The wrapper is also easier to read now — the ad-hoc per-arg switch
that used to live inline is gone.
Wires a mock claude binary + mock icc socket so the wrapper can run
its full code path (including the icc_socket_available gate) in
isolation. 18 assertions across 5 user-facing scenarios:
1. Cold launch: no prior id, no transcript → wrapper passes
--session-id <new uuid>, NOT --resume; writes the id under
$ICC_AGENT_SESSION_HOME/claude/<workspace>/<surface>.session.
2. Warm launch: prior id stored + transcript on disk → wrapper
passes --resume <prior-id>, NOT --session-id (would conflict).
3. Stale id: prior id stored but its transcript was wiped →
wrapper detects the missing .jsonl, falls back to --session-id
<fresh uuid>, and rotates the stored mapping to the new id.
4. User --resume <uuid>: wrapper passes the user's flag through
unchanged AND updates the stored mapping to the user's chosen
id, so the next imux relaunch resumes the same conversation.
5. User --continue: wrapper does not inject --session-id (would
conflict), passes --continue through cleanly, and leaves the
stored mapping untouched (--continue carries no UUID we can
capture without intercepting claude's picker).
The mock claude reads ICC_E2E_ARGV_LOG to record argv, so multiple
invocations can be inspected without the wrapper or mock having to
parse /dev/stdin. Mock socket is created via python's AF_UNIX bind
to satisfy the wrapper's [[ -S "$ICC_SOCKET_PATH" ]] gate.
MarkdownPanelPointerObserverView has the same shape as the FileDropOverlayView bug: weak forwardedMouseTarget cached at mouseDown, dispatched to in mouseDragged/mouseUp without checking that the cached view's superview chain still reaches contentView. A portal/split rebuild between mouseDown and mouseDragged would hand AppKit a detached view; nextResponder walk then loops. Fix: - mouseDragged + mouseUp now gate the forward through FileDropOverlayView.isForwardedTargetStillLive (the same helper that fixed the original 1.10.20 crash). - viewWillMove(toWindow: nil) drops the cache before AppKit tears down the parent chain, mirroring the FileDropOverlayView fix. The existing helper is already 'static internal'; nothing to expose. Two new regression tests in FileDropOverlayResponderRecursionTests pin that the helper is reusable across consumers (8/8 green).
Two SSH-related issues found while auditing the remote-connection
chain:
1. Stale-credential lockout
icc_ssh_run_expect always re-sent the same saved password on each
reconnect cycle. If the user rotated the server-side password,
keychain still held the old one and the wrapper's 2-second retry
loop would hammer SSH every 2 seconds with the wrong credential —
eventually locking out the account.
Fix: track consecutive auth failures (SSH exit codes 5 and 255).
After 2 in a row, print a clear hint ('update saved password via
Manage host → Password') and bail. Non-auth disconnects (network
blips, server-side close) reset the counter so genuine reconnects
still work.
2. Defense-in-depth password echo
expect's log_user 1 leaves spawn IO mirrored to the tty. SSH
servers normally disable terminal ECHO during a password prompt,
but a misconfigured server could surface the password on screen.
Toggle log_user 0 across the password write and re-enable
immediately after — interactive output post-auth stays normal.
Tests: 38/38 in WorkspaceRemoteConnectionTests still green.
Solves the remote-side gap in P1 session recovery: when imux opens
an SSH workspace, the user's prior conversation with claude/codex
on that host should resume the same way it does locally. Without
this, every reconnect spawned a fresh session because remote claude
had no idea about our (workspace, surface) → session-id mapping.
Mechanism:
1. RemoteWrapperBootstrap.swift builds a deterministic bash snippet
that materializes our 3 wrapper files (claude, codex,
imux-agent-common.sh) under $HOME/.icc/bin/ on the remote host
and prepends that dir to PATH. Files are base64-encoded inline
so no scp/rsync dependency. A 16-char FNV-1a digest of the
payload gates the write — reconnects are O(1) when nothing has
changed.
2. SidebarExplorers.workspaceConfiguration reads the local bundle's
wrapper bytes and emits the bootstrap snippet as the SSH remote
command:
ssh -t alias 'bash -lc "<bootstrap>; exec $SHELL -l"'
The user's login shell still runs interactively after the
bootstrap; the only externally visible change is that PATH now
has ~/.icc/bin first.
3. imux-agent-common.sh learned a cross-platform session root:
Darwin → ~/Library/Application Support/icc/agent-sessions
Linux/other → ~/.icc/agent-sessions
so the wrapper logic is identical local and remote.
4. icc_ssh_reset_terminal_history no longer clears the visible
scrollback on every reconnect — only on the first connect for
that PTY. Matches VS Code's remote-terminal behavior of
preserving the conversation log across drops.
Tests:
- 10 new unit tests in RemoteWrapperBootstrapTests pin the snippet
invariants: install dir creation, idempotent PATH prepend,
base64 payload, executable chmod, digest determinism + content
sensitivity + order independence + filename sensitivity, empty
payload no-op.
- 81 Swift + 31 shell + 18 e2e existing tests still green.
Out of scope (acceptable):
- Old Solaris hosts without 'base64' (the snippet's only hard
dependency) won't bootstrap. Modern Linux distros and macOS all
qualify.
- The bootstrap doesn't reach Windows/PowerShell remotes; those
require a separate strategy and aren't a current target.
VS Code parity gap: dragging files from Finder onto our sidebar's Files section did nothing. Now each row of the local file tree accepts file URLs from any source (Finder, mail attachments, web downloads). Behavior -------- - Drop on a directory row → land in that directory. - Drop on a file row → land in the file's parent directory (matches Finder/VS Code Explorer expectations). - Default operation = copy (non-destructive; the source stays put). - Hold Cmd during drop = move (consistent rule rather than mirroring Finder's volume-dependent default). - Same-name conflict = auto-pick 'name copy.ext' / 'name copy 2.ext' rather than overwrite. The auto-rename keeps the first 64 attempts predictable; beyond that we suffix with the unix timestamp. - Cycle guard: cannot move /a/b into /a/b/c (would create /a/b/c/b/c/b/...). Skipped silently. - Drop-target visual: while drag hovers a row we draw a 1.5pt accent outline so the user can see exactly where the drop will land. Architecture ------------ - New FinderSidebarDrop enum holds the pure decision logic — input: cursor path + modifiers + source URL + existence probes; output: .proceed/.conflict/.skip. No SwiftUI, no FileManager, fully unit-testable. - FileExplorerRow gains .onDrop(of: [.fileURL]) and a small set of static helpers (consumeProviders, currentModifiers) that bridge AppKit's NSItemProvider + NSEvent.modifierFlags into the pure vocabulary. - LocalFileExplorerSidebar.handleFinderDrop runs the real FileManager copy/move per-source after consulting decide() and refreshes the affected directory node so new entries appear. - Errors surface in the existing errorMessage banner — non-modal since the source is still in Finder. Tests ----- 15 new FinderSidebarDropTests cover modifier resolution (default copy, Cmd→move, option-alone-still-copies), destination resolution (directory passthrough, file→parent, empty→root), decision branches (.proceed, .conflict, .skip variants for source==dest, missing dir, descendant cycle), and name-suggestion (extension + extensionless + incrementing collisions). All injected via probes — no real FS needed.
Companion to e376c08 (local Files drops). Drag any file from Finder onto a remote SSH workspace's file tree and it uploads through the existing SSH ControlMaster channel. Behavior -------- - Drop on a directory row → upload into that directory. - Drop on a file row → upload to its POSIX parent directory. - Drop on the root area → upload to the workspace's current remoteDisplayDirectory (the one the user sees as 'cwd'). - Always overwrite (matches what users expect from a deliberate drop). No automatic rename on the remote — the destination path is exactly what you'd get from 'scp file remote:dir/'. - Folder uploads are deliberately not yet supported. We surface 'Folder uploads aren't supported yet.' in the inline error banner when only directories were dragged. A follow-up can add recursive upload via tar | ssh -- tar -x. Plumbing -------- - New RemoteSSHFileService.uploadFile streams local bytes through ssh stdin into 'cat > target' on the remote — same pattern as saveTextFile but binary-safe (Data(contentsOf:) instead of Data(text.utf8)). Timeout scales with file size: 30s base + ~1s per 512 KB. - RemoteFileExplorerNodeRows / RemoteFileExplorerRow gain onDropFiles closure plumbed up to RemoteWorkspaceExplorerSidebar.handleRemoteFinderDrop. - The drop visual reuses the same accent-tinted outline overlay introduced for the local sidebar, so both sides feel identical. - Static helpers FileExplorerRow.consumeProviders / currentModifiers are reused rather than duplicated — the AppKit bridge surface stays in one place. Tests ----- 3 new FinderSidebarDropTests assertions pin that the destination resolver works the same way for POSIX remote paths as it does locally (no separate code path needed). Total drop coverage is now 18 unit tests; full Swift suite 99 + shell 31 + e2e 18 all green.
Three follow-on improvements to the Finder→sidebar drop pipeline:
1. Recursive directory upload (remote)
RemoteSSHFileService.uploadDirectory streams a local directory
tree to the remote via a tar pipeline:
local tar c -C parent dirname | ssh remote 'tar x -C destDir'
Pure pipe — no intermediate archive on disk. Preserves binary
content, symlinks, modes. Timeout scales with the source's
total file size (60s base + 1s per 256 KB) so a 1 GB tree gets
~70 minutes max before bailing. handleRemoteFinderDrop now
dispatches files to uploadFile and dirs to uploadDirectory.
2. Intra-sidebar drag now explicitly tested
Dragging from one row to another in our own file tree already
worked because AppKit delivers the same public.file-url
payload as a Finder drag. Three new pinning tests fix the
contract:
- drop on own parent → skip(sourceIsDestination)
- drop into sibling dir → proceed(move)
- drop a directory onto itself → skip(wouldCreateCycle)
3. Progress banner
New SidebarDropProgress (ObservableObject) + SidebarDropProgressBanner
View. Both LocalFileExplorerSidebar and RemoteWorkspaceExplorerSidebar
now show a thin banner above the file tree:
- .running: 'Copying file.txt' or 'Copying 3 items (1/3)'
- .completed: green checkmark, 1.5s auto-fade
- .failed: red triangle + dismiss button, persists until clicked
Both sidebars now run their drop work via Task.detached so the
UI doesn't freeze on large trees.
Tests
-----
- 12 new SidebarDropProgressTests cover the state machine
(idle/running/completed/failed transitions, item counter clamps,
auto-idle window, failure persistence).
- 3 new FinderSidebarDropTests for intra-sidebar drag invariants.
- Total drop coverage 21 tests + 12 progress tests + existing
87 Swift / 31 shell / 18 e2e — 136 green.
Out of scope (acceptable for this pass)
---------------------------------------
- The remote tar pipeline assumes /usr/bin/tar exists on both
sides; this is true on every macOS and modern Linux distro.
- No real progress bytes-streamed metric — we count items, not
bytes. Bytes-level progress would need to wire NSProgress through
the Process pipe, which is a larger surface.
Two parity gaps with VS Code's Explorer closed in one pass: 1. Quick Look on local file rows New QuickLookPreviewBridge wraps QLPreviewPanel.shared() and gives each file row a 'Quick Look' menu item (space shortcut). Native macOS preview — same panel Finder uses, so PDFs, images, audio, code with syntax highlighting all light up for free. 2. Remote → remote drag move Dragging a row inside the remote SSH explorer now executes 'mv' on the remote host instead of bouncing the path through the conversation insert. The drop receiver inspects providers for our ExplorerConversationDragTransfer pasteboard type first; remote-path payload routes through a new handleRemoteIntraDrop, while plain fileURL drags continue to the upload path. Server-side new helper: - RemoteSSHFileService.moveRemoteEntry runs 'mkdir -p "$(dirname dst)" && mv -- src dst' over SSH. Refuses no-op self-moves locally (skip the SSH round-trip). Local cycle guard refuses moving a directory into its own descendant before bothering the server, so the user gets an immediate error banner instead of an opaque 'mv' failure. Drop progress banner reused: moves get the same running / completed / failed visual treatment as uploads. Tests: 71/71 green across drop, progress, and remote suites.
Pin standard editor keybindings on the local file row context menu so power users don't have to right-click for every action: - Cmd+Shift+Return → Rename (Finder uses Return alone, but we'd conflict with Open). - Cmd+Delete → Delete (matches Finder's 'move to trash' habit). - Cmd+Return → Insert into conversation. - Cmd+Option+C → Copy path (Cmd+C is reserved for the system text copy in case the row label is being edited). - Space → Quick Look (already wired in the previous commit, listed here for discoverability completeness). The shortcuts only fire while a row's context menu is the focused menu, which is what SwiftUI's .keyboardShortcut on a Button inside .contextMenu binds to. Full focusable-tree keyboard navigation (arrow keys, type-ahead) needs a richer focus story and is left as a follow-up.
VS Code parity gap: the local Files explorer only let you work on one row at a time. Now you can Cmd+click to toggle rows in and out of a selection, Shift+click to extend a range against the visible flatten of the tree, and the context menu offers batch equivalents when the set has more than one entry. Behavior -------- - Plain click → single select + set anchor (same behavior the old code had, just now routed through the selection state machine). - Cmd+click → toggle the clicked row, anchor moves to it. - Shift+click → range from anchor to clicked row, inclusive. Anchor stays put so successive shift+clicks re-extend from the same starting point. - Context menu gains 'Copy N Paths', 'Insert N Into Conversation', 'Delete N Items' entries below the single-row actions when the row participates in a multi-select. - Multi-select rows paint the selection fill so the set is visible at a glance (previously only selectedFilePath got highlight). Architecture ------------ - New ExplorerSelection (pure value type): paths + anchor, plus applyingClick / pruning / dragPaths helpers. Zero SwiftUI deps. - FileExplorerNodeRows/Row gain selectedPaths + isMultiSelectMember + onRowClick + onBatchAction props; plain-click expand/open still runs when modifiers are empty so directory toggling is unchanged. - LocalFileExplorerNode learns flattenVisiblePaths (used by range resolution) and descendant(matchingPath:) (used by batch delete to reuse the single-row prompt). - LocalFileExplorerSidebar owns @State ExplorerSelection, handles row click + batch action centrally. Pruning on refresh is wired so a 'rm' mid-session doesn't leave ghost selections. Tests ----- 18 ExplorerSelectionTests pin the state machine (plain / cmd / shift / anchor recovery / prune / drag-paths / constructors). Full Swift sweep 132 green + shell 31 + e2e 18 = 181/181. Out of scope (deferred) ----------------------- - Multi-file drag: SwiftUI .onDrag is limited to a single NSItemProvider. Dragging multiple rows to Finder/terminal requires switching to an NSViewRepresentable + NSDraggingSource. The batch context menu covers the 80% workflow (bulk copy paths, bulk insert, bulk delete) without that surface area. - Remote sidebar multi-select: only local side is wired this pass; the same ExplorerSelection value type can be reused when we port it, but remote requires separate SSH-side plumbing.
…ssion
Closes the two follow-ups left after the local multi-select
landing — both sidebars now have parity, and drags out to
Finder/terminal can carry the entire selection.
1. Remote multi-select
RemoteFileExplorerNode/Row gain selectedPaths +
isMultiSelectMember + onRowClick + onBatchAction props,
matching the local explorer. The same ExplorerSelection state
machine drives both sides — flattenVisiblePaths + descendant
ported to the remote node so shift-range and batch-delete know
the same vocabulary.
Batch context menu offers Copy N Paths / Insert N Refs /
Delete N Items. Delete prompts a destructive-confirmation
modal (no remote Trash to recover from), then hands the path
list to a single SSH round-trip.
2. RemoteSSHFileService.deleteRemoteEntries
New helper runs 'rm -rf -- p1 p2 ...' over SSH. Refuses
patently-unsafe paths ('/' / '~' / '~/') as a defense in
depth, so an upstream bug can't escalate through this
destructive endpoint. Each path goes through
shellSingleQuoted — embedded spaces, quotes, and unicode all
round-trip cleanly.
3. Multi-file drag via AppKit
New MultiFileDragHandle (NSViewRepresentable wrapping
NSDraggingSource) layers over each row that's part of a
multi-row selection. SwiftUI's .onDrag is single-provider,
so dragging from selected rows previously only shipped one
URL; the AppKit drag session here begins with N
NSDraggingItems (one per URL) so Finder, the terminal, and
any other receiver see the entire selection.
Drag visuals stack file icons with a 4pt offset to mirror
Finder's 'multiple files in flight' preview.
Slop threshold (16pt² = 4pt linear) prevents stray pointer
jitter from kicking off a drag — single-click → SwiftUI
Button still selects, mouse moves >4pt → AppKit promotes to
drag session.
Tests: 132 Swift + 31 shell + 18 e2e all green (181/181).
VS Code's signature 'Files: Go to File' palette now lives in our
local sidebar. Press ⌘P, type a few characters, hit Enter — the
matched file opens in the workspace.
Architecture
------------
Three new pure-Swift building blocks plus one SwiftUI view:
1. FuzzyMatcher (pure logic, 19 tests)
Subsequence matcher with VS Code-style scoring:
- consecutive runs (run length × 6 bonus per char)
- boundary bonus at start / after / _ - . space (+9)
- CamelCase boundary bonus (+7)
- basename-exact bonus (+50)
- first-match position penalty (-0.5/index)
- length penalty (-0.05/char)
Returns matched character indices for UI highlight; ranked()
sorts a candidate list with a deterministic alpha tie-breaker.
2. SidebarFileIndexer (10 tests)
Walks rootPath via FileManager.enumerator, skipping common
build/dependency dirs (.git, node_modules, DerivedData, target,
etc.) and hidden files by default. Caps at 50,000 entries so
a stray /tmp drop in a project root can't melt the indexer.
Symlinks/non-regular entries are filtered out.
3. SidebarQuickOpenModel
ObservableObject that owns the indexed paths + query +
selected index. prepare(rootPath:) builds the index off the
main thread; filteredResults is computed on demand from
FuzzyMatcher.ranked. moveSelection wraps for smooth ↑↓ feel.
4. SidebarQuickOpenPalette View
480pt floating panel with magnifying-glass + TextField + result
list (LazyVStack + ScrollViewReader auto-scroll-to-cursor).
Each row shows basename + parent path; matched chars in the
basename render in heavy/accent style. ↑↓ moves cursor, Enter
commits to onPick, Esc dismisses. .onKeyPress wired (macOS 14).
LocalFileExplorerSidebar wires it up:
- @StateObject SidebarQuickOpenModel + @State quickOpenVisible.
- Hidden Button on the background captures ⌘P (no menu surface
area, doesn't show up in tooltips).
- Overlay renders the palette over a dimming layer; tap-outside
dismisses; clicking a row also commits.
Tests
-----
- FuzzyMatcherTests: 19 cases covering match/miss decisions and
every score-ordering invariant we care about (run > scatter,
early > late, basename-exact wins big, etc.).
- SidebarFileIndexerTests: 10 cases on a real temp-dir tree —
skip rules, hidden inclusion knob, max-cap truncation, symlink
filtering, missing root → empty result.
- Full sweep: 161 Swift + 31 shell + 18 e2e = 210/210 green.
Out of scope (acceptable)
-------------------------
- Remote-side ⌘P: requires SSH 'find' or rsync-list to build the
remote index. Same UI/matcher reuses; only the indexer changes.
Scheduled as a follow-up so this commit can land without
cross-cutting the SSH plumbing.
- Persisted recents / scoring boost for recently-opened files.
VS Code does this; we can layer on top of FuzzyMatcher without
changing the API.
Two follow-ons that close the last big VS Code parity gaps in the
file explorer.
1. Remote ⌘P
SidebarQuickOpenModel learned a pluggable indexer closure;
default reads from SidebarFileIndexer (local), but the remote
sidebar swaps in an SSH-backed walker:
RemoteSSHFileService.indexRemoteFiles
runs
through the existing SSH ControlMaster, prunes the same
build/dependency dirs as the local indexer, drops hidden
files, caps at 50,000 entries via so the
overshoot bit detects truncation.
RemoteWorkspaceExplorerSidebar gains @StateObject
SidebarQuickOpenModel + a ⌘P background button. Overlay
gates rendering on remoteConfiguration so the palette never
renders in disconnected workspaces.
The palette View itself is unchanged; both sidebars share the
same fuzzy matcher, the same ↑/↓/Enter/Esc keyboard, and the
same matched-character highlight.
Picking a row hands the relative path back to the caller —
local sidebar joins it into a file URL, remote joins via POSIX
string concat and routes through onOpenRemoteFile.
2. Inline rename (local)
FileExplorerRow now renders a TextField in place of the Text
label when inlineRenamePath matches the row's path.
onSubmit commits via LocalExplorerMutationController.rename
(same path validation and error surfacing as the modal flow);
onExitCommand cancels.
Selecting Rename in the context menu (or pressing ⌘⇧↩) sets
inlineRenamePath to the row, the row autofocuses the field on
appear. No more sheet for the common case — Finder-style edit
in place.
The modal LocalExplorerNamePrompt path stays for the 'New
File' / 'New Folder' creation case where there's no
pre-existing row to edit; that flow is unchanged.
Tests: 161 Swift + 31 shell + 18 e2e = 210/210 green.
Out of scope (acceptable)
-------------------------
- Inline rename on remote rows: needs a remote-mv plumb that
doesn't exist yet. Same UI shape will work; only the commit
closure differs. Deferred.
Major release covering the v1.10.20 → v1.11.0 work: Added - ⌘P Quick Open palette (local + remote SSH workspaces) with VS Code-style fuzzy scoring and keyboard navigation. - Inline rename on local file rows (no more sheet for the common case). - Quick Look (Space) on local file rows. - Multi-row selection in both sidebars: Cmd-click toggle, Shift range, batch context menu (Copy / Insert / Delete N), and a multi-file AppKit drag session for receivers like Finder and the terminal. - Finder ↔ sidebar drop, both directions, including recursive directory upload via tar pipeline. - Per-(workspace, surface) session-id persistence for claude and codex; reopening a pane resumes the prior conversation. - Remote agent wrapper push to ~/.icc/bin/ on SSH connect, so remote claude / codex go through the same session-recovery layer as local. - SSH terminal scrollback preserved across reconnects. Fixed - Main-thread stack overflow on long sessions (P0): NSResponder recursion in FileDropOverlayView + MarkdownPanel. - SSH password lockout when remote password rotates (auto-bail after 2 auth failures). - Defense-in-depth password echo suppression in the SSH expect helper. Changed - Control-plane reorganization (S1): Sources/Control/Envelope + Router skeleton, ~31 v2 methods migrated to handler files. - HotPathSampler telemetry primitive lands as collection-only foundation for the v2 plan §5 budgets. npm: @calm2026/imux 1.10.4 → 1.11.0 (synced to desktop version).
…rver.sh Two CI fixes for release/v1.11.0: 1. web/bun.lock had drifted from web/package.json (added @swc/helpers transitive). bun install --frozen-lockfile rejected it on Linux. Re-run bun install locally to regenerate. 2. tests/test_web_download_server.sh was committed without the +x bit. macOS 'chmod +x' didn't propagate into the git index, so the Linux workflow-guard runner refused to execute it. git update-index --chmod=+x fixes the recorded mode.
Previous fix updated git's index mode but a subsequent 'git add' from the working copy (where macOS still recorded 644) overwrote it back. Set the executable bit on disk first this time so 'git add' picks up the right mode and the index stays at 100755.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Major release covering the v1.10.20 → v1.11.0 work.
Highlights
File Explorer (VS Code parity)
Session Recovery (claude + codex)
~/.icc/bin/on SSH connect, so remote claude/codex go through the same session-recovery layer.Reliability (P0)
FileDropOverlayView+MarkdownPanel.expecthelper.Architecture
Sources/Control/Envelope+ Router skeleton; ~31 v2 methods migrated to handler files.HotPathSamplertelemetry primitive for v2 plan §5 budgets.npm
@calm2026/imux: 1.10.4 → 1.11.0 (synced to desktop version).
Tests
161 Swift + 31 shell + 18 e2e = 210/210 green at HEAD.
Thanks to 1 contributor!