diff --git a/.claude/skills/check-upstream-changes/SKILL.md b/.claude/skills/check-upstream-changes/SKILL.md index ad6ba57c..ae265576 100644 --- a/.claude/skills/check-upstream-changes/SKILL.md +++ b/.claude/skills/check-upstream-changes/SKILL.md @@ -9,7 +9,7 @@ Check upstream (supabitapp/supacode) for new changes since the last reviewed bas Follow these steps: -1. Read `doc-onevcat/change-list.md` and extract the **Upstream Baseline** commit hash and date. +1. Read `docs-ai/017-upstream-sync-process/upstream-ledger.md` and extract the **Upstream Baseline** commit hash and date. 2. Fetch the upstream remote: ```bash git fetch upstream main --quiet @@ -23,7 +23,7 @@ Follow these steps: - Commit hash (short) - PR number if visible in the commit message - Brief description of the change - - Whether it might conflict with or overlap existing fork customizations (check `doc-onevcat/change-list.md` Old Log for context) + - Whether it might conflict with or overlap existing fork customizations (check `docs-ai/017-upstream-sync-process/upstream-ledger.md` Old Log for context) 5. Categorize commits into: - **Needs attention** — changes that may conflict with fork patches or require manual review - **Safe to merge** — additive features, docs, version bumps, or fixes with no fork overlap diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 9de836c3..0d22588c 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -30,13 +30,13 @@ Build, sign, notarize, and publish a Prowl release. 4. Determine the version: - If `$ARGUMENTS` is provided, use it as the version (e.g., `2026.3.18`) - Otherwise, default to today's date format and confirm with the user before proceeding -5. Generate release notes: `./doc-onevcat/scripts/release-notes.sh ` +5. Generate release notes: `./scripts/release-notes.sh ` - This script compares HEAD against the previous release tag, gathers commits and PR descriptions, and generates user-facing notes via LLM into `build/release-notes.md`. - Read the generated `build/release-notes.md`, show the content to the user, and wait for explicit confirmation. If the user wants changes, edit the file directly. - **Do NOT proceed to the next step until the user confirms the release notes.** -6. Run the release script: `./doc-onevcat/scripts/release.sh ` +6. Run the release script: `./scripts/release.sh ` - The script reads `build/release-notes.md` (required — refuses to run without it). - It handles: version bump, build, sign, notarize, DMG, appcast, GitHub Release, and Prowl-Site update. If the tag already exists (e.g., from a prior interrupted run), diff --git a/README.md b/README.md index 80d0ee0a..f0dcac4f 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ make sync-ghostty # Force rebuild + clear DerivedData Day-to-day releases are driven by the `release` [Claude Code](https://claude.com/product/claude-code) skill defined in [`.claude/skills/release/SKILL.md`](.claude/skills/release/SKILL.md). It wraps two scripts you can also run directly: ```bash -./doc-onevcat/scripts/release-notes.sh # Generate user-facing notes → build/release-notes.md -./doc-onevcat/scripts/release.sh # Bump, build, sign, notarize, DMG, appcast, GitHub Release, Prowl-Site update +./scripts/release-notes.sh # Generate user-facing notes → build/release-notes.md +./scripts/release.sh # Bump, build, sign, notarize, DMG, appcast, GitHub Release, Prowl-Site update ``` The skill walks the flow interactively: verify branch & tree state, confirm the version, review the generated notes, then run `release.sh`. All fork releases are notarized. diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md deleted file mode 100644 index cad8b10f..00000000 --- a/doc-onevcat/active-agents-panel-task-log.md +++ /dev/null @@ -1,38 +0,0 @@ -# Active Agents Panel Task Log - -## 2026-05-09 - -### Scope - -- Implement Phase 0, Phase 1, and Phase 2 from `doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md`. -- Keep commits small enough to audit. -- Maintain high test coverage for pure detection logic and state transitions. - -### Progress - -- Started from branch `feat/active-agents-panel`. -- Initial worktree was clean; only existing branch commit was the implementation plan. -- Confirmed Xcode uses file-system synchronized root groups, so new Swift source/test files under `supacode/` and `supacodeTests/` are picked up automatically. - -### Decisions And Notes - -- Pure detection logic is implemented first with tests before wiring, because it is the most important stable contract for later UI iteration. -- Created `onevcat/ghostty` and pushed `release/v1.3.1-patched` with `ghostty_surface_pid`. -- `make sync-ghostty` fails under Xcode 26.4.1 before compiling Ghostty sources because Zig 0.15.2 cannot link the native build runner. Re-running with `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer` succeeds. -- Added Active Agents reducer/UI wiring and terminal detection loop. `GhosttySurfaceBridge.childPID()` uses `dlsym` so the app still compiles before the patched GhosttyKit binary is rebuilt; after rebuild, the exported `ghostty_surface_pid` symbol is used automatically. -- The Active Agents panel height is persisted globally but visually capped by the sidebar container height, reserving at least 200 pt for the repository list. -- Agent display names intentionally use short command-style lowercase tokens (`pi`, `claude`, `codex`, `kimi`, etc.) because the panel is a compact terminal-status surface, not product branding. -- Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. -- The Active Agents footer toggle uses stable `person.crop.rectangle.stack` / `person.crop.rectangle.stack.fill` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. -- Added DEBUG-only agent detection diagnostics for child PID lookup, foreground process group, candidate processes, identified/retained agent, raw screen state, and stabilized state after manual testing showed no agents appearing in the panel. -- Added `ghostty_surface_foreground_process_group` to the Ghostty fork and switched Swift detection to prefer Ghostty's pty foreground process group over `proc_bsdinfo.e_tpgid`, which was nil for the shell PID during manual testing. - -### Verification - -- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/DetectedAgentTests -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 19 tests. -- `make check` passed after keeping the `pi` agent case name and disabling SwiftLint's `identifier_name` rule on that enum case only. -- `xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" 2>&1 | xcsift -f toon -w` passed. -- `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty` completed successfully. -- `nm -gU Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/libghostty.a | rg 'ghostty_surface_(pid|process_exited)'` finds both `_ghostty_surface_pid` and `_ghostty_surface_process_exited`. -- `make build-app` completed successfully after building the app and embedded CLI. -- `make test` passed 1038 tests with GhosttyKit already up-to-date. diff --git a/doc-onevcat/canvas-exit-terminal-blank-tracking.md b/doc-onevcat/canvas-exit-terminal-blank-tracking.md deleted file mode 100644 index 989a0520..00000000 --- a/doc-onevcat/canvas-exit-terminal-blank-tracking.md +++ /dev/null @@ -1,61 +0,0 @@ -# Canvas Exit Terminal Blank Closure - -Last updated: 2026-04-29 -Status: Closed - -## Outcome - -The Canvas exit / entry blank terminal issue has not reappeared after the host -ownership fix and occlusion reapply safeguards landed. Treat this investigation -as closed unless a new report includes a fresh reproduction pattern. - -## Root Cause - -The most likely failure mode was host ownership loss during SwiftUI/AppKit -reparenting: - -- Canvas and terminal wrappers both host the same `GhosttySurfaceView`. -- A stale terminal wrapper could attempt to reattach a surface that was already - owned by the live Canvas wrapper. -- When that stale wrapper later deinitialized, AppKit removed the surface again, - leaving the active host blank even though reducer selection and tab state were - still correct. - -## Fixes Kept - -- Terminal hosts only defensively reattach orphaned surfaces; they do not steal a - surface from another live host. -- Occlusion state invalidates on attachment changes so the latest desired value - is resent after reattachment. -- Un-occluding is deferred until a surface has both a superview and a window; - occluding remains immediate so detached surfaces do not keep rendering. -- Canvas-managed terminal states avoid normal window-activity sync while Canvas - owns visibility. - -## Remaining Logs - -Most investigation logs were removed. The retained low-frequency logs are: - -- `[CanvasExit] enteringCanvas` -- `[CanvasExit] setSelectedWorktreeID` -- `[CanvasExit] deferOcclusion` -- `[CanvasExit] hostReattach` -- `[CanvasExit] hostReattachComplete` -- `[TerminalWake]` runtime sleep/wake summaries - -These are enough to identify a regression without keeping wrapper lifecycle, -tab appear/disappear, attachment-change, or call-stack logging in normal builds. - -## Residual Risk - -The remaining risk is in AppKit view lifecycle ordering. If a future SwiftUI -layout change introduces another host that can own `GhosttySurfaceView`, it must -follow the same rule: only adopt orphaned surfaces and never move a surface away -from another live host. - -Relevant coverage: - -- `GhosttySurfaceViewTests.terminalHostDoesNotStealSurfaceFromCanvasHost` -- `GhosttySurfaceViewTests.canvasHostDoesNotStealDetachedSurfaceBack` -- `GhosttySurfaceViewTests.terminalHostReattachesSurfaceOnlyAfterItLeavesTheViewTree` -- occlusion reattachment tests in `GhosttySurfaceViewTests` diff --git a/doc-onevcat/plans/2026-03-20-repository-snapshot-cache-design.md b/doc-onevcat/plans/2026-03-20-repository-snapshot-cache-design.md deleted file mode 100644 index 9d02e576..00000000 --- a/doc-onevcat/plans/2026-03-20-repository-snapshot-cache-design.md +++ /dev/null @@ -1,79 +0,0 @@ -# Repository Snapshot Cache Design - -## Goal - -Add a small startup cache that restores repository UI immediately on app launch, while keeping live repository discovery as the only source of truth. - -## Principles - -- Keep the cache disposable. -- Keep the payload small and structural. -- Do not let bad cache data affect settings loading. -- Always run a normal live refresh after cache restore. -- Only overwrite the cache after a complete successful live load. - -## Storage - -Use a standalone JSON file at `~/.prowl/repository-snapshot.json`. - -Reasoning: - -- Cache decode failures stay isolated from `settings.json`. -- The file can be deleted safely with no migration burden. -- The payload can evolve with an explicit schema version. - -## Payload - -Persist only data needed for first paint: - -- repositories in UI order -- repository root path -- repository display name -- worktree name -- worktree detail string -- worktree working-directory path -- worktree `createdAt` - -Do not cache: - -- PR state -- line changes -- watcher state -- notifications -- `lastFocusedRepositoryID` - -Selection restoration continues to use existing `lastFocusedWorktreeID` persistence. - -## Invalidation - -Treat the cache as a miss when any of the following happens: - -- file is missing or empty -- schema version mismatch -- JSON decode failure -- any cached repository root path no longer exists -- any cached worktree path no longer exists - -When invalid, discard the cache file and continue with a normal live load. - -## Startup Flow - -1. Load pinned/archive/order/last-focused persisted state. -2. Load repository snapshot cache. -3. If snapshot exists, restore repositories into state immediately. -4. Mark initial load complete so the main UI renders. -5. Start the usual live repository loading flow. -6. Apply live results to the UI. -7. If the live load succeeds with no repository failures, overwrite the snapshot file. - -## Refresh Rules - -Overwrite the snapshot only after complete successful repository loads: - -- initial startup refresh -- manual refresh -- other flows that end in a full successful repository snapshot - -Do not overwrite the snapshot on partial or failed loads. - -No TTL is needed because the cache is only a startup accelerator. Freshness comes from the unconditional live refresh that always runs after startup. diff --git a/doc-onevcat/plans/2026-03-24-plain-folder-support-plan.md b/doc-onevcat/plans/2026-03-24-plain-folder-support-plan.md deleted file mode 100644 index 5fa5f1b8..00000000 --- a/doc-onevcat/plans/2026-03-24-plain-folder-support-plan.md +++ /dev/null @@ -1,530 +0,0 @@ -# Plain Folder Support Implementation Plan - -## Goal - -Allow users to add any folder to the sidebar. - -Git repositories must keep their current behavior. -Plain folders must become first-class selectable items with a usable detail view, reusable non-git settings, and capability-gated actions. - -## Scope - -In scope: - -- add plain folders from the existing Add Repository flow -- persist and restore mixed `git` and `plain` entries -- show plain folders in the sidebar -- allow selecting a plain folder as a real target -- support repository-level detail for plain folders -- reuse non-git repository settings for plain folders -- gate git-only behavior through shared capabilities -- keep mixed states working: git repos, plain folders, failed git loads - -Out of scope: - -- pull request support for plain folders -- branch operations for plain folders -- diff / line change tracking for plain folders -- worktree creation / archive / delete for plain folders -- GitHub integration for plain folders - -## Principles - -- Model `plain` folders in the domain layer, not only in UI row models. -- Avoid fake worktrees for non-git folders. -- Treat repository selection and worktree selection as different concepts. -- Use capabilities to gate behavior instead of scattering `kind == .git` checks. -- Migrate persistence explicitly instead of inferring long-term meaning from paths alone. -- Reuse existing settings UI where the semantics still make sense. - -## Key Decisions - -### 1. Repository Modeling - -Extend `Repository` with explicit identity beyond `rootURL` and `worktrees`. - -Recommended shape: - -- `Repository.Kind` - - `.git` - - `.plain` -- `RepositoryCapabilities` - - `supportsWorktrees` - - `supportsBranchOperations` - - `supportsPullRequests` - - `supportsDiff` - - `supportsGitStatus` - - `supportsRunnableFolderActions` - - `supportsRepositoryGitSettings` - -Notes: - -- `kind` expresses what the repository is. -- `capabilities` expresses what the UI and reducers may do with it. -- Views should prefer capabilities over direct kind checks. - -### 2. Selection Model - -Current behavior is effectively worktree-centric. -That is not sufficient once a repository may have zero worktrees. - -The selected target needs to become explicit: - -- repository selection remains valid for all repositories -- worktree selection remains valid for git repositories -- plain folders rely on repository selection, not synthetic worktree selection - -The sidebar repository row must stop being only an expand/collapse control. -It must become a real selectable item for plain folders, and likely for git repositories as well to keep the model coherent. - -### 3. Persistence Model - -Replace path-only persistence with an explicit entry model. - -Recommended persisted entry: - -```swift -struct PersistedRepositoryEntry: Codable, Equatable, Sendable { - var path: String - var kind: Repository.Kind -} -``` - -Migration strategy: - -- continue decoding legacy `repositoryRoots: [String]` -- transform legacy roots into persisted entries during load -- write back only the new structure after the first successful save - -This same explicit kind must also be reflected in the repository snapshot payload. - -### 4. Settings Reuse - -Keep `RepositorySettingsFeature` as the shared entry point. - -Behavior: - -- plain folders reuse non-git settings -- git/worktree-only sections are hidden by capability -- git-only async loading is skipped when unsupported - -Expected reusable areas: - -- open action -- run script -- custom commands -- onevcat-specific settings that do not require git metadata - -Expected hidden areas: - -- base ref options -- branch-derived defaults -- worktree creation options that only make sense for git -- bare repository handling - -## Data Flow Changes - -### Add Repository Flow - -Current flow: - -1. file importer returns folder URLs -2. reducer resolves each URL through `gitClient.repoRoot` -3. failures are rejected as invalid roots - -Target flow: - -1. file importer returns folder URLs -2. reducer tries to resolve each URL as a git repository -3. if resolution succeeds, store a `.git` entry for the resolved root -4. if resolution fails, store a `.plain` entry for the original folder -5. merged persisted entries are saved -6. live loading builds repositories from those entries - -This makes "not a git repository" a supported path instead of an error. - -### Reload Flow - -Current reload assumes all stored roots are git repositories. - -Target reload must: - -- load persisted repository entries -- for `.git`, fetch worktrees and build a git repository -- for `.plain`, build a plain repository with zero worktrees -- only record load failures for entries that were expected to be git but cannot currently load - -### Snapshot Flow - -Snapshot caching must persist enough information to restore both kinds. - -Required additions: - -- repository kind -- zero-worktree repositories must be valid snapshot content - -Invalidation rules remain disposable and conservative. - -## UI Plan - -### Sidebar - -Sidebar needs to support three distinct row concepts: - -- repository row -- worktree row -- special rows such as archived worktrees / canvas - -Repository row behavior: - -- selectable -- expandable where relevant -- capability-driven actions - -Plain folder sidebar behavior: - -- selecting the repository row selects the folder -- expand/collapse may be disabled or become a no-op if there are no children -- git-only affordances are hidden - -Git repository sidebar behavior: - -- existing child worktree presentation remains -- repository row still supports selection, not only expansion -- worktree rows remain individually selectable - -### Detail View - -Plain folders need a repository-level detail branch. - -V1 repository detail for plain folders should support: - -- name and path presentation -- open action -- run script action -- custom commands -- empty-state style explanation for unavailable git features - -Git repositories keep the current worktree-based terminal detail flow. - -### Toolbar and Menus - -Toolbar and context menus must be driven by the selected target's capabilities. - -Hide for plain folders: - -- rename branch -- pull request actions -- diff actions backed by git line changes -- worktree archive / delete actions -- new worktree - -Keep for plain folders where meaningful: - -- open in configured destination -- copy path -- run script -- custom commands -- repository settings -- remove repository - -## Reducer and Client Work - -### RepositoriesFeature - -Primary changes: - -- introduce repository entry loading instead of raw root loading -- update open / reload / restore flows -- make repository selection a first-class reducer concept -- make worktree creation resolution ignore repositories without `supportsWorktrees` -- skip git-only effects for repositories lacking required capabilities - -Specific hotspots: - -- `openRepositories` -- `loadPersistedRepositories` -- `reloadRepositories` -- `loadRepositoriesData` -- `repositoryForWorktreeCreation` -- `canCreateWorktree` -- repository row selection behavior -- alert text and empty-state copy - -### AppFeature - -Primary changes: - -- stop assuming all useful selection state comes from `selectedWorktree` -- support repository-level selection for plain folders -- keep worktree-driven terminal setup only for git worktrees -- drive settings and open actions from the selected target - -Likely additions: - -- repository-level settings loading path -- repository-level open/run/custom-command behavior - -### WorktreeInfoWatcher - -Only git worktrees should be sent into watcher infrastructure. - -This means: - -- plain repositories contribute nothing to watcher state -- PR refresh scheduling ignores plain repositories -- line change scheduling ignores plain repositories - -### Command Palette - -Command palette must filter items by capability. - -Keep for plain folders: - -- open repository -- open settings -- refresh -- repository selection -- run/custom command actions if targetable - -Hide for plain folders: - -- new worktree -- PR actions -- archive/delete worktree actions - -## Settings Plan - -### RepositorySettingsFeature - -Keep the feature, but make it repository-aware instead of implicitly git-aware. - -Changes: - -- accept repository kind and capabilities in state -- skip git requests when git capabilities are absent -- hide unsupported settings sections in the view layer -- preserve existing behavior for git repositories - -Risk: - -- current settings loading is frequently driven from selected worktree root URL -- plain folders may require a repository-level settings load path to avoid worktree-only assumptions - -### Repository Settings Storage - -Existing per-root settings storage can remain keyed by root path. - -That is still valid for plain folders as long as: - -- the root path is stable -- unsupported fields are ignored or hidden -- old git-specific values do not break plain folder UI - -## Migration Plan - -### Persisted Settings - -Add new persisted repository-entry storage while continuing to read the old `repositoryRoots` field. - -Migration steps: - -1. decode new entries if present -2. otherwise decode legacy roots -3. map legacy roots to default entries -4. save back in the new format on the next write - -Default entry mapping for legacy roots: - -- attempt git discovery during load -- persist the detected kind after the first successful save - -### Snapshot Cache - -Bump snapshot schema version. - -Rules: - -- old snapshot versions are discarded -- new snapshot supports both `.git` and `.plain` -- zero-worktree repositories are valid snapshot content - -## Testing Strategy - -### Reducer Tests - -Add reducer coverage for: - -- adding a plain folder -- adding mixed plain and git folders -- reloading mixed persisted entries -- selecting a plain repository -- preventing worktree creation on plain folders -- gating git-only actions on plain folders - -### Persistence Tests - -Add persistence coverage for: - -- legacy path-only data migration -- new repository entry encode/decode -- snapshot restore for plain repositories -- snapshot invalidation on schema mismatch - -### Command Palette Tests - -Add coverage for: - -- plain folder selection entries remain visible -- git-only command palette items disappear for plain folders -- mixed repository sets still prune recency correctly - -### Settings Tests - -Add coverage for: - -- plain folders skip git metadata loading -- reusable settings persist for plain folders -- unsupported git sections remain hidden or inactive - -### Integration Validation - -Manual validation checklist: - -1. Add a non-git folder. -2. Restart the app and confirm it restores. -3. Select the folder and confirm detail view is usable. -4. Open the folder via the configured open action. -5. Confirm run script and custom commands remain available. -6. Confirm git-only actions are absent. -7. Add a git repository and confirm current worktree flow still works. -8. Confirm mixed sidebar ordering and selection remain stable. - -## Milestones - -### Milestone 1: Domain and Persistence - -- add `Repository.Kind` -- add repository capabilities -- add persisted repository entry model -- migrate settings storage -- migrate snapshot payload - -Tasks: - -1. Add `Repository.Kind` and `RepositoryCapabilities` to the domain model. -2. Add repository-entry persistence models and legacy decode compatibility. -3. Update repository snapshot payload to persist `kind`. -4. Add persistence tests for legacy migration and mixed restore. - -### Milestone 2: Discovery and Loading - -- update Add Repository flow -- update reload flow -- build plain repositories during live load -- keep failure handling only for real git load failures - -Tasks: - -1. Introduce repository-entry loading in `RepositoryPersistenceClient`. -2. Update `openRepositories` to classify `.git` vs `.plain`. -3. Update live reload paths to load mixed entries. -4. Add reducer tests for add/reload of mixed git/plain repositories. - -### Milestone 3: Selection and Detail - -- make repository selection first-class -- add plain folder detail -- update sidebar repository row behavior - -Tasks: - -1. Promote repository selection to a real reducer/view state transition. -2. Update sidebar repository rows to support selection without breaking expand/collapse. -3. Add repository-level detail view for plain folders. -4. Add reducer and detail-view tests for plain folder selection. - -### Milestone 4: Capability Gating - -- gate reducer actions -- gate watcher feeds -- gate toolbar, menus, command palette - -Tasks: - -1. Gate worktree creation and other reducer entry points by capability. -2. Exclude plain repositories from watcher and PR refresh feeds. -3. Hide unsupported toolbar, row, and command palette actions. -4. Add tests for capability-driven command palette and reducer behavior. - -### Milestone 5: Settings Reuse - -- reuse repository settings for plain folders -- hide git-only sections -- add repository-level settings path where needed - -Tasks: - -1. Make `RepositorySettingsFeature` accept repository capabilities. -2. Skip git metadata loading when unsupported. -3. Hide git-only settings sections while preserving reusable fields. -4. Add tests covering plain-folder settings loading and persistence. - -### Milestone 6: Validation and Cleanup - -- migration tests -- mixed-state regression tests -- copy cleanup -- final build verification - -Tasks: - -1. Update empty-state and alert copy to mention folders, not only git repositories. -2. Add mixed-state regression coverage across repositories, snapshots, and settings. -3. Run targeted test suites for each milestone. -4. Run final app build verification. - -## Execution Order - -Recommended implementation order: - -1. finish domain and persistence first -2. wire repository discovery and reload -3. make selection and detail coherent -4. gate git-only actions by capability -5. adapt settings reuse -6. run regression and build verification - -This order keeps the model stable before touching broad UI surfaces. - -## Change Plan - -Use one `jj` change per milestone-sized task group: - -1. `plan: detail plain-folder support tasks` -2. `model: add repository kind and persisted entries` -3. `load: support plain folders in repository discovery` -4. `ui: support repository selection and plain folder detail` -5. `capability: gate git-only actions for plain folders` -6. `settings: reuse repository settings for plain folders` -7. `verify: add regression coverage and final copy cleanup` - -Execution workflow: - -- describe each change up front -- `jj edit` into the target change before implementation -- follow TDD for logic-layer work inside each change -- keep tests green before moving to the next change - -## Risks - -- The largest risk is hidden dependence on `selectedWorktree` across reducers and views. -- The second largest risk is repository settings still being loaded indirectly from worktree state. -- A fake-worktree shortcut would appear faster but would increase long-term complexity and should be avoided. -- Mixed persisted state must stay deterministic to avoid sidebar flicker or accidental load failures after migration. - -## Rollout Notes - -- This work is large enough to land in multiple focused commits or PR-sized slices. -- Domain and persistence should land before broad UI refactors. -- Each milestone should keep the app compiling even if the full feature is not yet user-complete. diff --git a/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-design.md b/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-design.md deleted file mode 100644 index 8c93077f..00000000 --- a/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-design.md +++ /dev/null @@ -1,691 +0,0 @@ -# Canvas Multi-Select and Broadcast Input Design - -## Goal - -Let Canvas select multiple cards and send the same user input to all selected cards, with a strong emphasis on: - -- natural multi-card selection on macOS (`Cmd+Click`) -- direct typing into Canvas without a separate batch-input textbox -- correct non-English input behavior -- preserving current single-card interaction when multi-select is not active - -This design targets the two main user scenarios discussed: - -1. Open multiple cards backed by different agents and send the same prompt to compare results. -2. Operate multiple remote SSH sessions and apply the same command/configuration to all of them. - ---- - -## Non-Goals - -This design does **not** try to make multiple terminals behave like a perfectly synchronized remote desktop. - -Out of scope for v1: - -- broadcasting mouse interactions to multiple cards -- broadcasting search UI, text selection, or context menus -- mirroring IME candidate windows/preedit UI to follower cards -- guaranteeing perfect behavior for all full-screen TUIs (`vim`, `fzf`, `less`, `top`, etc.) -- changing sidebar multi-selection or worktree detail selection behavior outside Canvas - ---- - -## Current Architecture Summary - -Canvas today is fundamentally a **single-focus** experience: - -- `CanvasView` stores a single `focusedTabID`. -- `CanvasCardView` only allows terminal hit testing when the card is focused. -- Canvas exit behavior uses the focused canvas card to decide which worktree/tab to return to. -- Terminal command routing is mostly **worktree-scoped**, while Canvas cards are effectively **tab-scoped**. - -Relevant current implementation points: - -- `supacode/Features/Canvas/Views/CanvasView.swift` -- `supacode/Features/Canvas/Views/CanvasCardView.swift` -- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` -- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` -- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` -- `supacode/App/CommandKeyObserver.swift` - -Important constraints from current code: - -1. A card maps to a **tab**, not only a worktree. -2. Input routing for active terminals depends on the focused `GhosttySurfaceView`. -3. `GhosttySurfaceView` already supports AppKit IME (`NSTextInputClient`) and distinguishes: - - marked/preedit text (`setMarkedText` / `syncPreedit`) - - committed text (`insertText`) -4. `CommandKeyObserver` already exists app-wide and can be reused to drive `Cmd`-based selection affordances. - ---- - -## User Experience Design - -## High-Level Model - -Canvas supports: - -- **primary focus**: the card that owns the real first responder and drives local input/IME -- **multi-selection**: zero, one, or many selected cards -- **selection mode**: a temporary click-interpretation mode entered by `Cmd+Click` - -The key distinction is: - -- **focus** decides where real AppKit/Ghostty input originates -- **selection** decides which cards receive mirrored input - -These are related but not identical. - ---- - -## Selection Rules - -### Entering selection mode - -- `Cmd+Click` on any unselected card region enters selection mode. -- The clicked card is added to selection. -- The clicked card becomes the **primary selected card**. - -"Any card region" includes the terminal content area, not only the title bar. - -### While selection mode is active - -- `Cmd+Click` on an unselected card adds it to selection. -- `Cmd+Click` on a selected card removes it from selection. -- If removal leaves one selected card, Canvas may stay visually selected but effectively returns to single-card behavior. -- Clicking empty canvas clears selection and exits selection mode. - -### Select all - -- `Cmd+Opt+A` selects all visible cards for broadcast. -- A toolbar button provides the same action with tooltip showing the hotkey. -- If a primary card already exists, it is preserved; otherwise the last visible card becomes primary. - -### While broadcasting (multiple cards selected, mode idle) - -When multiple cards are selected and the user has begun typing (mode transitions from `.selecting` to `.idle`), the following behaviors apply: - -- **Non-Cmd click on a follower card**: promotes it to primary without clearing multi-selection. -- **Non-Cmd click on the primary card**: passes through to the terminal (shield is not shown on primary during broadcasting). -- **Non-Cmd click on an unselected card**: clears multi-selection and focuses that single card. -- **`Cmd+Click`**: toggles selection as usual. -- **`Escape`**: clears all selection and exits broadcast mode. - -### Leaving selection mode - -The mode should be intentionally short-lived and should end on the first normal interaction. - -- Any **non-Command keyboard input** when multiple cards are selected: - - exits the pure selection state - - immediately becomes a broadcast-input interaction -- Clicking empty canvas: - - clears all selected cards - - clears primary focus in Canvas (0-selection is allowed) - -This keeps selection lightweight and avoids sticky modifier-heavy behavior. - ---- - -## Focus and Primary Card Semantics - -When multiple cards are selected, exactly one selected card is still the **primary** card. - -The primary card is responsible for: - -- owning the real first responder -- owning the visible IME composition/preedit state -- serving as the source of mirrored input -- deciding the worktree/tab used when exiting Canvas back to the normal terminal view - -Selection without a primary card is invalid. - -If the primary card is removed from selection: - -- pick the most recently added remaining selected card as the new primary, or -- if that history is unavailable, pick a deterministic fallback (e.g. the last card toggled on) - ---- - -## Visual Design - -### Selected card styling - -Cards have two visual states: - -- **primary focused/selected** card: 2pt accent-colored focus ring -- **follower selected** cards: 1.5pt accent ring at 65% opacity + subtle background tint - -### Broadcast hint - -When more than one card is selected, a capsule badge appears in the bottom-right toolbar: - -- `Broadcasting to N cards` - -This is informational only, not a dedicated text entry field. - -A separate textbox is intentionally rejected because it makes the interaction feel unlike a terminal. - -### Toolbar - -The canvas toolbar (bottom-right) contains: - -- **Select All** button (`checkmark.rectangle.stack` icon) — tooltip: "Select all cards for broadcast (⌘⌥A)" -- **Arrange** button — preserves card sizes -- **Organize** button — uniform grid layout - ---- - -## Input Behavior Design - -## Core Principle - -When multiple cards are selected, the user still types **once** into the primary card. -Canvas mirrors that input to follower cards. - -This should feel like: - -- one real terminal under the cursor -- N-1 follower terminals receiving mirrored input - ---- - -## IME / Non-English Input Behavior - -This is the most important rule: - -> Followers must receive committed characters/words, not the phonetic keystrokes used to compose them. - -Examples: - -- Chinese Pinyin input should mirror `你好`, not `nihao` -- Japanese input should mirror committed kana/kanji text, not unfinished romaji sequences - -### IME behavior in v1 - -#### Primary card - -The primary card handles the full native IME lifecycle as it does today: - -- marked text / preedit -- candidate window -- commit -- cancel - -#### Follower cards - -Follower cards do **not** render IME preedit/candidate UI. -They receive only the final committed text. - -That means: - -- while the user is composing, followers may show no change yet -- once composition commits, followers receive the committed string immediately - -This is the intended design, not a degradation. -It is the safest way to guarantee that non-English input remains semantically correct. - ---- - -## Broadcast Categories - -Input fan-out is split into two classes. - -### 1. Committed text broadcast - -Used for: - -- English text input that arrives as text -- committed IME text -- pasted text (Cmd+V: after Ghostty handles the paste binding in `performKeyEquivalent`, reads `NSPasteboard.general` string and fires `onCommittedText`) - -Behavior: - -- take the committed string from the primary card -- insert the same committed string into each follower card - -### 2. Normalized special-key broadcast - -Used for: - -- `Enter` -- `Backspace` / `Delete` -- arrow keys (`↑ ↓ ← →`) -- `Tab` -- `Escape` -- common shell control keys (for example `Ctrl-C`, `Ctrl-D`, `Ctrl-L`) -- `Cmd+Backspace` (delete line) -- `Cmd+Arrow` keys (line/word navigation) - -Behavior: - -- normalize the originating primary-card key event into a small mirror-safe model -- replay that normalized input on followers - -### Whitelisted Cmd combinations - -A static whitelist (`commandAllowedKeyCodes`) controls which Cmd+key combinations pass through. Currently allowed: - -- `Cmd+Backspace` (keyCode 51) -- `Cmd+Arrow Left/Right/Down/Up` (keyCodes 123–126) - -All other Cmd combinations are filtered out. - -### Explicitly excluded from broadcast - -Do not broadcast: - -- `Cmd` shortcuts not in the whitelist (e.g. `Cmd+C`, `Cmd+W`, `Cmd+Q`) -- menu shortcuts -- window/app commands -- mouse events -- IME marked/preedit updates - -This keeps the feature aligned with terminal input rather than app control. - ---- - -## Implementation Design - -## 1. Canvas Selection State - -Selection state lives in `CanvasView` as `@State private var selectionState = CanvasSelectionState()`. - -`CanvasSelectionState` is a pure value type with: - -```swift -struct CanvasSelectionState: Equatable { - enum Mode: Equatable { case idle, selecting } - - private(set) var mode: Mode - private(set) var selectedTabIDs: Set - private(set) var primaryTabID: TerminalTabID? - private(set) var selectionOrder: [TerminalTabID] - - var isSelecting: Bool // mode == .selecting - var isBroadcasting: Bool // selectedTabIDs.count > 1 - - mutating func focusSingle(_ tabID: TerminalTabID) - mutating func toggleSelection(_ tabID: TerminalTabID) - mutating func setPrimary(_ tabID: TerminalTabID) - mutating func selectAll(_ tabIDs: [TerminalTabID]) - mutating func beginBroadcastInteractionIfNeeded() - mutating func clear() - mutating func prune(to visibleTabIDs: Set) -} -``` - -### Why keep this in `CanvasView` for v1 - -The behavior is Canvas-local and highly UI-driven. -There is no strong need to move it into TCA reducer state yet. - -The pure `CanvasSelectionState` struct makes the transition logic fully testable without SwiftUI. - ---- - -## 2. Cmd+Click Anywhere on a Card - -### Problem - -Today the focused terminal content receives hit testing, which means the terminal area would normally steal clicks. -A title-bar-only approach is not acceptable. - -### Implemented solution: selection shield overlay + per-card visibility - -When either of the following is true: - -- `CommandKeyObserver.isPressed == true`, or -- `selectionMode == .selecting` - -Canvas places a transparent hit-testing layer over every visible card. - -Additionally, during **broadcasting** (multiple cards selected, mode idle): - -- follower cards keep the shield (intercept clicks for `setPrimary` behavior) -- the primary card does **not** show the shield (allows terminal click-through) - -This is computed per-card via `showsSelectionShield(for: TerminalTabID) -> Bool`. - -### Cmd key detection - -**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. - -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). - ---- - -## 3. Make Broadcast Tab-Scoped, Not Worktree-Scoped - -Current terminal commands are mostly scoped by `Worktree`. -Canvas cards are scoped by `TerminalTabID`. - -### Tab-targeted helpers on `WorktreeTerminalState` - -```swift -func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool -func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool -``` - -### Lookup and broadcast helpers on `WorktreeTerminalManager` - -```swift -func stateContaining(tabId: TerminalTabID) -> WorktreeTerminalState? -func broadcastCommittedText(_ text: String, from: TerminalTabID, to: Set) -> Int -func broadcastMirroredKey(_ key: MirroredTerminalKey, from: TerminalTabID, to: Set) -> Int -``` - -Broadcast failures are logged via `SupaLogger` for debugging. - ---- - -## 4. Broadcast Hooks on `GhosttySurfaceView` - -### Callbacks - -```swift -var onCommittedText: ((String) -> Void)? -var onMirroredKey: ((MirroredTerminalKey) -> Void)? -``` - -- `onCommittedText` fires in `insertText()` after text is committed, and in `performKeyEquivalent` after Ghostty handles a Cmd+V binding. -- `onMirroredKey` fires in `keyDown()` when the event normalizes to a `MirroredTerminalKey`. - -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. - -### `MirroredTerminalKey` - -```swift -struct MirroredTerminalKey: Equatable, Sendable { - enum Kind: Equatable, Sendable { - case enter, backspace, deleteForward - case arrowUp, arrowDown, arrowLeft, arrowRight - case tab, escape, controlCharacter - } - - let kind: Kind - let keyCode: UInt16 - let characters: String - let charactersIgnoringModifiers: String - let modifierFlagsRawValue: UInt // raw UInt for Sendable conformance - let isRepeat: Bool - - var modifiers: NSEvent.ModifierFlags { ... } // computed from raw value -} -``` - -The struct stores `modifierFlagsRawValue` (raw `UInt`) instead of `NSEvent.ModifierFlags` to satisfy `Sendable` conformance, since callbacks cross async boundaries via `Task { @MainActor in }`. - -A static whitelist (`commandAllowedKeyCodes`) allows specific Cmd+key combinations through; all other Cmd events return `nil` from the initializer. - ---- - -## 5. Safe Follower Insertion APIs - -```swift -func insertCommittedTextForBroadcast(_ text: String) -func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool -``` - -- `insertCommittedTextForBroadcast(_:)` writes committed UTF-8 text directly to the surface via `ghostty_surface_text`. -- `applyMirroredKeyForBroadcast(_:)` replays a normalized key on the target surface via `keyDown`/`keyUp` without making it the app first responder. - -Follower cards **never** steal first responder during broadcast. The primary card remains the real focused AppKit responder. - ---- - -## 6. Event Flow - -### A. Multi-select click flow - -1. User holds `Cmd`. -2. Canvas enables selection shield overlays (may have up to 300ms delay from observer). -3. User clicks any card region. -4. `onTap` or `onSelectionTap` fires; both check `NSEvent.modifierFlags.contains(.command)` for reliable detection. -5. Canvas toggles that `tabID` in `selectedTabIDs`. -6. Canvas updates `primaryTabID` if needed. -7. Ghostty does not consume that click. - -### B. Click during broadcasting (multiple cards selected) - -1. User clicks a follower card without `Cmd`. -2. Follower card has selection shield (per-card shield logic). -3. `handleSelectionShieldTap` detects `isBroadcasting` and the card is selected. -4. Canvas calls `setPrimary` — promotes the clicked card to primary without clearing multi-selection. -5. If the user clicks the **primary** card (no shield), the click passes through to the terminal. - -### C. IME composition on primary card - -1. User types with IME on the primary card. -2. Primary card receives `setMarkedText(...)` and updates preedit locally. -3. No follower update happens yet. -4. User commits a candidate. -5. Primary card receives `insertText(...)` with committed text. -6. `onCommittedText` callback fires, broadcasting committed string to followers. - -### D. Paste broadcast (Cmd+V) - -1. User presses Cmd+V on the primary card. -2. `performKeyEquivalent` detects Cmd+V has a Ghostty binding, calls `keyDown(with: event)`. -3. Ghostty internally performs `paste_from_clipboard` and writes clipboard content to the primary surface. -4. After `keyDown` returns, `performKeyEquivalent` reads `NSPasteboard.general.string(forType: .string)` and fires `onCommittedText`. -4. Broadcast callbacks mirror the pasted text to all follower cards. - -### E. Enter key broadcast - -1. Primary card receives Enter. -2. Primary card submits normally. -3. `onMirroredKey` emits `.enter` mirrored key. -4. Followers receive `.enter` via `applyMirroredKeyForBroadcast`. - ---- - -## 7. Interaction With Existing Canvas Exit Behavior - -Current Canvas exit uses the focused canvas card to decide which worktree/tab to restore. -That continues to use the **primary selected card** via `canvasFocusedWorktreeID`. - -Rules: - -- if multiple cards are selected, exiting Canvas returns to the primary card's owning worktree/tab -- if selection was cleared and no primary remains, Canvas exits to the prior normal fallback behavior -- clicking empty canvas may leave Canvas with 0 selection and 0 focused card; this is acceptable - ---- - -## Alternatives Considered - -## Rejected: title-bar-only multi-select - -Rejected because users must be able to select from the terminal area too. -In Canvas, the card is the object, not only its title bar. - -## Rejected: dedicated batch-input textbox - -Rejected because it makes terminal broadcast feel indirect and unlike the rest of Prowl. -Direct typing is the intended interaction. - -## Rejected: full raw-event mirroring for IME - -Rejected because it would risk propagating phonetic composition keys (`nihao`, romaji, etc.) instead of committed text. -Correct multilingual output is more important than perfect preedit mirroring. - -## Rejected: separate `onPasteText` callback - -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. - ---- - -## Suggested File-Level Changes - -### Primary feature files - -- `supacode/Features/Canvas/Views/CanvasView.swift` - - selection state (`CanvasSelectionState`) - - selection-mode transitions via `mutateSelection` - - broadcast callback setup/teardown via `syncBroadcastCallbacks` - - broadcast status UI in toolbar - - selection shield (per-card via `showsSelectionShield(for:)`) - - `Cmd+Opt+A` select all, `Escape` to clear - - `NSEvent.modifierFlags` for reliable Cmd detection in tap handlers - -- `supacode/Features/Canvas/Views/CanvasCardView.swift` - - selected/follower styling (border color, line width, background tint) - - selection shield overlay (`onSelectionTap`) - - normal terminal hit testing preserved outside selection/broadcast mode - -### Selection model - -- `supacode/Features/Canvas/Models/CanvasSelectionState.swift` - - pure value type for selection transitions - -### Terminal model / manager - -- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` - - tab-scoped `insertCommittedText` and `applyMirroredKey` - -- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` - - `stateContaining(tabId:)` lookup - - `broadcastCommittedText` / `broadcastMirroredKey` fan-out with debug logging - -### Ghostty bridge - -- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` - - `onCommittedText` / `onMirroredKey` callbacks - - `insertCommittedTextForBroadcast` / `applyMirroredKeyForBroadcast` follower APIs - - paste broadcast via `onCommittedText` in `paste()` - - IME preedit stays primary-only - -- `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift` - - normalized key model with `Sendable` conformance - - `commandAllowedKeyCodes` whitelist for Cmd+Backspace/Arrow - ---- - -## Verification Strategy - -## Automated - -### Pure selection-state tests (`CanvasSelectionStateTests`) - -- `focusSingle` sets primary and clears selection mode -- `toggleSelection` enters selection mode and appends order -- toggling selected primary promotes previous selection -- toggling last selected card clears state -- `beginBroadcastInteraction` leaves selection set but exits selection mode -- `setPrimary` promotes follower without clearing selection -- `setPrimary` ignores unselected tab -- `selectAll` selects every tab and keeps existing primary -- `selectAll` from empty picks last tab -- `prune` drops missing tabs and preserves newest visible primary - -### Mirrored key tests (`MirroredTerminalKeyTests`) - -- Enter event normalizes correctly -- Command-modified events are filtered out (e.g. Cmd+C returns nil) -- Cmd+Backspace is allowed through whitelist -- Cmd+Arrow is allowed through whitelist -- Control character event normalizes correctly -- Plain text event does not normalize as mirrored key - -## Manual - -### Shell / SSH - -- select 2+ SSH cards -- type a command like `pwd` -- verify all cards receive the same text -- press Enter -- verify all cards execute once -- test `Ctrl-C` -- test `Cmd+Backspace` (delete line) -- test `Cmd+V` paste - -### Agent prompt comparison - -- select 2+ agent cards -- type the same prompt -- verify all cards receive the same committed prompt text - -### IME - -- use Chinese Pinyin -- compose text in primary card -- verify followers do not show phonetic intermediate text -- commit the candidate -- verify followers receive committed Chinese text - -- repeat with Japanese input - -### Selection UX - -- `Cmd+Click` terminal area of focused and unfocused cards -- ordinary click exits selection mode correctly -- blank-canvas click clears selection -- `Cmd+Opt+A` selects all cards -- `Escape` clears broadcast selection -- click follower during broadcasting promotes to primary -- click primary during broadcasting passes through to terminal -- exit Canvas returns to the primary card's worktree/tab - ---- - -## Risks - -1. **Ghostty/AppKit event ordering** - - follower replay must not interfere with the primary first responder - -2. **IME edge cases** - - candidate confirmation behavior may differ by input method - - design intentionally limits follower behavior to committed text - -3. **Complex TUIs** - - some full-screen or mouse-driven apps may not behave intuitively under broadcast - - acceptable for v1 - -4. **Click/drag interaction overlap** - - card drag gestures and selection clicks must be thresholded cleanly - -5. **CommandKeyObserver delay** - - 300ms hold delay means shield may not render for fast Cmd+Click - - mitigated by reading `NSEvent.modifierFlags` directly in tap handlers - ---- - -## Recommended Delivery Shape - -Implement this in slices: - -### Slice 1 -- selection state model -- Cmd+Click anywhere using selection shield -- follower selected styling -- clear/exit behavior - -### Slice 2 -- tab-scoped terminal helpers -- primary/follower broadcast plumbing -- committed text broadcast -- Enter/backspace/arrows/basic control keys - -### Slice 3 -- IME hardening -- paste behavior (Cmd+V broadcast) -- Cmd+Backspace/Arrow whitelist -- select all (Cmd+Opt+A) -- Escape to clear broadcast -- per-card shield during broadcasting -- edge-case polish and manual verification - -This keeps UX validation separate from lower-level Ghostty input fan-out. - ---- - -## Final Recommendation - -Proceed with a design that treats Canvas multi-select as: - -- **card-level selection anywhere on the card**, not title-bar-only -- **primary-card-driven live broadcast**, not a separate textbox -- **IME commit-text mirroring**, not phonetic keystroke mirroring - -That combination best matches the requested UX while staying implementable in the current Prowl/Ghostty architecture. diff --git a/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-implementation-plan.md b/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-implementation-plan.md deleted file mode 100644 index c6ef5b4b..00000000 --- a/doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-implementation-plan.md +++ /dev/null @@ -1,154 +0,0 @@ -# Canvas Multi-Select Broadcast Implementation Plan - -**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. - -**Scope:** -- In: - - Canvas-local multi-selection state and transitions - - Cmd+Click selection across full card area - - Primary vs follower selected styling - - Broadcast of committed text plus a small set of normalized special keys - - Whitelisted Cmd+key broadcast (Cmd+Backspace, Cmd+Arrow) - - Cmd+V paste broadcast via pasteboard string - - Cmd+Opt+A select all, Escape to clear - - Per-card selection shield during broadcasting - - IME-safe follower behavior using committed text only - - Tests for selection state transitions and input normalization/filtering -- Out: - - Mouse broadcast - - Full TUI parity for all applications - - Follower-side IME candidate/preedit UI - -**Architecture:** -- Keep selection state local to Canvas as `@State var selectionState = CanvasSelectionState()`. -- 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). -- Keep one primary card as the real first responder; mirror input from it to follower cards. -- Use `NSEvent.modifierFlags` for immediate Cmd detection in tap handlers (bypasses `CommandKeyObserver`'s 300ms hold delay). -- Introduce `MirroredTerminalKey` (Sendable) for normalized key replay with a Cmd-key whitelist. -- Treat IME specially: only committed text fans out; preedit stays primary-only. -- Broadcast paste content by reading `NSPasteboard.general` string in `paste()` and firing `onCommittedText`. - -**Acceptance / Verification:** -- Cmd+Click anywhere on a card toggles selection. -- Non-Cmd click exits selection mode and returns to single-card interaction. -- Non-Cmd click on a follower during broadcasting promotes it to primary. -- Non-Cmd click on the primary during broadcasting passes through to terminal. -- Clicking blank canvas clears selection and focus. -- Escape clears broadcast selection. -- Cmd+Opt+A selects all visible cards. -- Multiple selected cards receive mirrored committed text. -- Cmd+V paste text is broadcast to followers. -- Cmd+Backspace and Cmd+Arrow are broadcast to followers. -- Followers receive committed Chinese/Japanese text, not phonetic intermediate input. -- Build passes and targeted tests pass. - -## Task 1: Add pure Canvas selection state machine ✅ - -**Files:** -- Created: `supacode/Features/Canvas/Models/CanvasSelectionState.swift` -- Created: `supacodeTests/CanvasSelectionStateTests.swift` - -**Delivered:** -- Pure `CanvasSelectionState` struct with `focusSingle`, `toggleSelection`, `setPrimary`, `selectAll`, `beginBroadcastInteractionIfNeeded`, `clear`, `prune`. -- 10 tests covering all state transitions. - -## Task 2: Integrate selection model into CanvasView ✅ - -**Files:** -- Modified: `supacode/Features/Canvas/Views/CanvasView.swift` - -**Delivered:** -- Replaced `focusedTabID` with `selectionState: CanvasSelectionState`. -- `mutateSelection` helper centralizes state mutation, pruning, focus sync, and callback sync. -- z-order respects primary > selected > unselected. - -## Task 3: Add selected/follower visuals and selection shield hooks ✅ - -**Files:** -- Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift` - -**Delivered:** -- Primary: 2pt accent border. Follower: 1.5pt accent at 65% opacity + background tint. -- `selectionShield` overlay intercepts clicks via `onSelectionTap`. -- Resize handles hidden when shield is active. -- Terminal hit testing: `allowsHitTesting(isFocused && !showsSelectionShield)`. - -## Task 4: Wire Cmd+Click anywhere on card ✅ - -**Files:** -- Modified: `supacode/Features/Canvas/Views/CanvasView.swift` -- Modified: `supacode/Features/Canvas/Views/CanvasCardView.swift` - -**Delivered:** -- `showsSelectionShield(for:)` is per-card: all cards during `Cmd`/selecting; only followers during broadcasting. -- `onTap` checks `NSEvent.modifierFlags.contains(.command)` directly for reliable Cmd detection (bypasses 300ms observer delay). -- `handleSelectionShieldTap` dispatches to `toggleSelection`, `setPrimary`, or `focusSingle` based on Cmd state and broadcasting state. -- Blank-canvas click clears selection. - -## Task 5: Add normalized mirrored-key model ✅ - -**Files:** -- Created: `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift` -- Created: `supacodeTests/MirroredTerminalKeyTests.swift` - -**Delivered:** -- `MirroredTerminalKey: Equatable, Sendable` with kinds: enter, backspace, deleteForward, arrows, tab, escape, controlCharacter. -- Stores `modifierFlagsRawValue: UInt` for Sendable (computed `modifiers` property). -- `commandAllowedKeyCodes` whitelist: Cmd+Backspace (51), Cmd+Arrow (123–126). All other Cmd combos rejected. -- 6 tests covering normalization, Cmd filtering, whitelist, and plain-text rejection. - -## Task 6: Add Ghostty broadcast hooks and safe follower APIs ✅ - -**Files:** -- Modified: `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` - -**Delivered:** -- `onCommittedText` callback: fires in `insertText()` and in `paste()` (reads pasteboard string). -- `onMirroredKey` callback: fires in `keyDown()` for normalized keys. -- `insertCommittedTextForBroadcast(_:)`: writes UTF-8 text via `ghostty_surface_text`. -- `applyMirroredKeyForBroadcast(_:)`: replays NSEvent via `keyDown`/`keyUp` without stealing responder. - -## Task 7: Add tab-scoped terminal broadcast helpers ✅ - -**Files:** -- Modified: `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` -- Modified: `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` - -**Delivered:** -- `WorktreeTerminalState.insertCommittedText(_:in:)` and `applyMirroredKey(_:in:)`. -- `WorktreeTerminalManager.stateContaining(tabId:)` lookup. -- `broadcastCommittedText` / `broadcastMirroredKey` fan-out methods with `@discardableResult` return count. -- Debug logging via `SupaLogger` for broadcast failures. - -## Task 8: Connect primary-card input to follower broadcast ✅ - -**Files:** -- Modified: `supacode/Features/Canvas/Views/CanvasView.swift` - -**Delivered:** -- `syncBroadcastCallbacks` sets `onCommittedText`/`onMirroredKey` on primary surface's leaves only when broadcasting. -- `clearBroadcastCallbacks` nils out all callbacks on all surfaces. -- Callbacks use explicit capture list with `beginBroadcast` closure for safe `selectionState` mutation. -- Callbacks re-sync after split operations on primary card. -- Callbacks sync on `onAppear`, `onChange(allCardKeys)`, `onChange(allTabIDs)`, `mutateSelection`, `pruneSelection`, `deactivateCanvas`. - -## Task 9: Add Canvas keyboard shortcuts and toolbar ✅ - -**Files:** -- Modified: `supacode/Features/Canvas/Views/CanvasView.swift` - -**Delivered:** -- `.onKeyPress(.escape)`: clears selection when broadcasting. -- `.onKeyPress("a", phases: .down)` with `keyPress.modifiers == [.command, .shift]`: selects all cards. -- Toolbar: select-all button + broadcasting badge + arrange + organize. - -## Task 10: Polish and verification ✅ - -**Delivered:** -- Fixed reversed canvas scroll direction (removed incorrect delta negation in `CanvasScrollContainerView`). -- Fixed unsafe `selectionState` capture in broadcast callbacks. -- Made `MirroredTerminalKey` Sendable via raw UInt storage. -- Added Cmd+Backspace/Arrow whitelist. -- Added Cmd+V paste broadcast. -- All tests pass. Build passes. Lint passes. -- Design and implementation plan docs updated to match final implementation. diff --git a/doc-onevcat/plans/2026-04-04-cli-install-command.md b/doc-onevcat/plans/2026-04-04-cli-install-command.md deleted file mode 100644 index 45d83a55..00000000 --- a/doc-onevcat/plans/2026-04-04-cli-install-command.md +++ /dev/null @@ -1,182 +0,0 @@ -# CLI Install Command Implementation Plan - -**Goal:** Allow users to install the `prowl` CLI tool from within the Prowl app via three entry points: Settings, Prowl menu, and Command Palette. - -**Scope:** -- In: CLIInstallClient dependency, Advanced Settings UI, Prowl menu item, Command Palette item, AppFeature wiring, Makefile CLI embedding, tests -- Out: Auto-prompting on first launch, uninstall UI (can be added later), CLI build as part of Xcode build phase (manual `make build-cli` for now) - -**Architecture:** -- `CLIInstallClient`: TCA dependency client that handles symlink creation, status checking, and bundled binary path resolution -- Install action lives in `AppFeature` — all three entry points (Settings, Menu, Command Palette) funnel into the same `installCLI` action -- CLI binary is embedded at `Prowl.app/Contents/Resources/prowl-cli/prowl` -- Installation creates a symlink: `/usr/local/bin/prowl` → bundled binary path -- Advanced Settings gets a new "Command Line Tool" section showing install status + install/uninstall button - -**Acceptance / Verification:** -- `make build-app` succeeds -- All existing tests pass -- New CLIInstallClient tests pass -- New AppFeature CLI install reducer tests pass -- Menu item "Install Command Line Tool" visible under Prowl menu -- Command Palette shows "Install Command Line Tool" item -- Settings > Advanced shows CLI install section with status and action button - ---- - -## Task 1: Create CLIInstallClient dependency - -**Files:** -- Create: `supacode/Clients/CLIInstall/CLIInstallClient.swift` - -**Steps:** -1. Create `CLIInstallClient` struct following `WorkspaceClient` pattern -2. Provide operations: - - `bundledCLIURL: @Sendable () -> URL?` — returns `Bundle.main.resourceURL/prowl-cli/prowl` - - `installationStatus: @Sendable () -> CLIInstallStatus` — checks if symlink exists and points to correct target - - `install: @Sendable (URL) async throws -> Void` — creates symlink at given path (default `/usr/local/bin/prowl`) - - `uninstall: @Sendable (URL) async throws -> Void` — removes symlink at given path -3. Define `CLIInstallStatus` enum: `.notInstalled`, `.installed(path: String)`, `.installedDifferentSource(path: String)` -4. Implement `DependencyKey` with `liveValue` and `testValue` -5. Register in `DependencyValues` - -**Notes:** -- Use `FileManager` for symlink operations -- `install` should create `/usr/local/bin` directory if it doesn't exist -- Check if destination already exists before creating symlink; if it's a symlink pointing elsewhere, report `.installedDifferentSource` - ---- - -## Task 2: Add CLI install actions to AppFeature - -**Files:** -- Modify: `supacode/Features/App/Reducer/AppFeature.swift` (add actions and reducer cases) - -**Steps:** -1. Add new actions to AppFeature.Action: - - `installCLI` - - `uninstallCLI` - - `cliInstallResult(Result)` — result of install/uninstall with success message or error -2. Add `@Dependency(CLIInstallClient.self)` to AppFeature -3. Implement reducer cases: - - `installCLI`: run `.install()` via client, send result action - - `uninstallCLI`: run `.uninstall()` via client, send result action - - `cliInstallResult`: show alert with success/failure message -4. Add `CLIInstallError` type for error reporting - ---- - -## Task 3: Add CLI install section to Advanced Settings - -**Files:** -- Modify: `supacode/Features/Settings/Views/AdvancedSettingsView.swift` (add CLI section) - -**Steps:** -1. Add a new `Section("Command Line Tool")` in `AdvancedSettingsView` -2. Show current installation status (use `CLIInstallClient` to check) -3. Show Install/Uninstall button based on status -4. Button sends action to the `AppFeature` store (the settings view already receives `StoreOf`, but we need to access `AppFeature` actions — use a callback or add delegate actions) - -**Design decision:** Since AdvancedSettingsView only has `StoreOf`, add delegate actions to SettingsFeature: -- `SettingsFeature.Delegate.installCLIRequested` -- `SettingsFeature.Delegate.uninstallCLIRequested` -- Handle these in AppFeature's `.settings(.delegate(...))` case - -**Notes:** -- Show the install path (`/usr/local/bin/prowl`) in the UI -- Show a green checkmark or status text for installed state -- The view should refresh status when the settings tab appears - ---- - -## Task 4: Add menu item in Prowl menu - -**Files:** -- Modify: `supacode/App/supacodeApp.swift` (add menu item in Prowl menu group) - -**Steps:** -1. Add a `CommandGroup(after: .appSettings)` or within the existing Prowl menu area -2. Add "Install Command Line Tool..." button -3. Button sends `store.send(.installCLI)` action -4. Add appropriate `.help()` text - ---- - -## Task 5: Add Command Palette item - -**Files:** -- Modify: `supacode/Features/CommandPalette/CommandPaletteItem.swift` (add Kind case) -- Modify: `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` (add item, delegate, mapping) -- Modify: `supacode/Features/App/Reducer/AppFeature.swift` (handle new delegate) - -**Steps:** -1. Add `case installCLI` to `CommandPaletteItem.Kind` -2. Update `isGlobal` and `isRootAction` to return `true` for `.installCLI` -3. Add `case installCLI` to `CommandPaletteFeature.Delegate` -4. Add `CommandPaletteItem` to `commandPaletteItems()` function -5. Add ID `globalInstallCLI` to `CommandPaletteItemID` -6. Add to `globalIDs` array -7. Update `delegateAction(for:)` mapping -8. Update `appShortcutCommandID` (return nil for installCLI) -9. Handle `.commandPalette(.delegate(.installCLI))` in AppFeature reducer - ---- - -## Task 6: Makefile integration for CLI embedding - -**Files:** -- Modify: `Makefile` (add target to build CLI for bundle) - -**Steps:** -1. Add `build-cli-release` target: `swift build -c release --product prowl` -2. Add `embed-cli` target: copies release binary to `Resources/prowl-cli/prowl` -3. Update `build-app` to depend on `embed-cli` (or document manual step) -4. Add `Resources/prowl-cli/` to Xcode "Copy Bundle Resources" if not auto-included - -**Notes:** -- For development, `Resources/prowl-cli/prowl` can be a placeholder — the actual install will use the bundled path at runtime - ---- - -## Task 7: Tests for CLIInstallClient - -**Files:** -- Create: `supacodeTests/CLIInstallClientTests.swift` - -**Steps:** -1. Test `installationStatus` returns `.notInstalled` when no symlink exists -2. Test `installationStatus` returns `.installed` when valid symlink exists -3. Test `installationStatus` returns `.installedDifferentSource` when symlink points elsewhere -4. Test `install` creates symlink at expected path -5. Test `install` creates parent directory if needed -6. Test `uninstall` removes symlink -7. Test `uninstall` does not remove non-symlink files (safety) - -**Notes:** -- Use temp directories for test isolation -- Test with actual FileManager operations (not mocks) for the live client - ---- - -## Task 8: Tests for AppFeature CLI install reducer - -**Files:** -- Create: `supacodeTests/AppFeatureCLIInstallTests.swift` - -**Steps:** -1. Test `.installCLI` action triggers client install call -2. Test `.uninstallCLI` action triggers client uninstall call -3. Test success result shows appropriate alert -4. Test failure result shows error alert -5. Test Command Palette delegate `.installCLI` forwards to `.installCLI` action -6. Test Settings delegate `.installCLIRequested` forwards to `.installCLI` action - ---- - -## Task 9: Build verification - -**Steps:** -1. Run `make build-app` — verify success -2. Run existing tests — verify no regressions -3. Run new tests — verify all pass -4. Run `make lint` — verify no lint errors diff --git a/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md b/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md deleted file mode 100644 index b3d35e29..00000000 --- a/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md +++ /dev/null @@ -1,617 +0,0 @@ -# Sidebar Container Refactor Plan - -Status: planning -Issue: [#249](https://github.com/onevcat/Prowl/issues/249) -Related: [#222](https://github.com/onevcat/Prowl/issues/222) - -## Goal - -Refactor the repository sidebar so each repository behaves as one stable visual and drag unit, while worktrees remain selectable, reorderable, and efficient to update. - -This should fix the structural mismatch where the app treats repositories as reorderable units but SwiftUI `List` sees repository headers and worktree rows as separate rows. That mismatch shows up as: - -- incorrect repository drag insertion indicators when dragging downward across expanded repositories -- unstable bulk expand/collapse animations -- potential sidebar flicker during drag when live terminal, notification, or ordering updates arrive - -## Current Findings - -### 1. Repository sections are not actual list rows - -`SidebarListView` renders repositories through an outer `ForEach(...).onMove`. - -`RepositorySectionView` then returns: - -```swift -Group { - header - .tag(SidebarSelection.repository(repository.id)) - if isExpanded { - WorktreeRowsView(...) - } -} -``` - -In practice, the outer data model says "repository row", but `List` receives separate rows: - -```text -Repository A header - Repository A worktree - Repository A worktree -Repository B header - Repository B worktree -``` - -This explains the observed downward-drag indicator bug: - -```text -Target Repo header -o----------- -Target Repo worktree -``` - -SwiftUI is placing the indicator between list rows. It does not know that the target repository header and its worktree rows should be treated as one repository-level drop zone. - -### 2. `List(selection:)` is doing too much - -The current `List` carries several behaviors at once: - -- archived worktree selection and repository list header -- repository row selection for plain folders -- worktree multi-selection -- repository expand/collapse -- native repository reorder -- native worktree reorder for pinned and unpinned groups -- reveal-in-sidebar via `ScrollViewReader.scrollTo` -- native sidebar styling and accessibility - -Canvas, Shelf, and the footer are not `List` rows today; they are safe-area inset chrome around the list. The refactor should preserve that boundary unless a later design intentionally moves them into the scroll content. - -### 3. Live state still reaches rows during drag - -Some expensive state reads have already been isolated, such as moving repository tab-count reads into `RepoHeaderTabCountBadge`. - -Remaining drag-time churn sources include: - -- `WorktreeRowsView` animates changes to `rowIDs` -- each worktree row reads terminal notification/task/run-script state -- notification-driven reorder can call `withAnimation(.snappy)` and mutate `worktreeOrderByRepository` -- row hover/action UI changes while a drag session is active - -These are not necessarily the root cause of the drop-indicator bug, but they are credible contributors to #222-style flicker. - -## Revised Direction - -Use a custom sidebar scroll container rather than trying to keep the current flat `List` structure. - -Execution order matters: first stabilize the old `List` path with a reducer-level drag gate, then replace the visual structure. The drag gate is a hard prerequisite because it reduces #222 risk before the broader #249 rewrite starts. - -Recommended shape: - -```text -SidebarView chrome -├── top safeAreaInset buttons -│ ├── Canvas -│ └── Shelf -├── ScrollViewReader -│ └── ScrollView -│ └── LazyVStack or VStack -│ ├── repository list header -│ ├── RepositoryContainerRow -│ │ ├── RepositoryHeaderRow -│ │ └── WorktreeRows -│ ├── FailedRepositoryRow -│ └── ArchivedWorktreesRow -└── bottom safeAreaInset footer -``` - -Key property: repository containers are the only repository-level siblings in the outer stack. Expanded worktrees are children inside the container, not siblings beside it. - -This aligns UI boundaries with model boundaries: - -- repository reorder indicators target repository containers -- expand/collapse animates inside a container -- worktree reorder indicators target worktree rows inside one container -- live worktree updates do not change the outer repository list shape - -## Options Considered - -### Option A: Keep `List`, wrap worktrees inside one repository row - -Pros: - -- preserves some native sidebar styling -- repository-level `onMove` might remain mostly native - -Cons: - -- nested selectable worktree rows inside a single `List` row no longer participate naturally in `List(selection:)` -- worktree-level `onMove` becomes awkward inside a row -- native selection and keyboard behavior still need replacement -- likely keeps a hard-to-debug mix of native and custom drag logic - -This option reduces the indicator bug but does not cleanly solve the broader sidebar design. - -### Option B: Move fully to `ScrollView` + explicit rows - -Pros: - -- model and visual structure match -- repo and worktree drag/drop can be made explicit and testable -- selection, focus, and reveal behavior are owned by our code instead of `List` side effects -- easier to freeze drag-time updates intentionally -- eliminates `List` cell reuse as a class of expand/collapse animation bugs - -Cons: - -- must replace native `List(selection:)` -- must rebuild keyboard navigation, multi-selection, reorder, and accessibility affordances -- more implementation work - -This remains the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom. - -### Option C: Short-term drag-time freeze only - -Pros: - -- small -- may help #222 flicker - -Cons: - -- does not fix repository insertion indicator because row boundaries remain wrong -- leaves the main structural mismatch in place - -This is now the mandatory M1 prerequisite for Option B, not a replacement for Option B. - -## Hard Requirements - -The refactor must preserve these behavior and state contracts. - -### Reducer actions and persistence - -- Keep the existing reducer actions and persistence paths for repository and worktree ordering unless a later implementation note explicitly proves a rename is worth it. -- Preserve calls behind repository reorder, pinned worktree reorder, unpinned worktree reorder, and notification-driven reorder. -- Treat failed repository reorder semantics as an explicit product decision: - - either failed repository rows are reorderable and their order persists through the same root ordering path - - or they are not reorderable and the UI gives consistent feedback with no insertion target around them - -### Expanded and collapsed state - -- Preserve `@Shared` write-back semantics for collapsed repository IDs. -- Preserve cleanup of invalid collapsed IDs when repository IDs change. -- Ensure bulk expand/collapse and single expand/collapse share the same model path. - -### Focused actions and selection synchronization - -- Preserve `SidebarView` focused values for `confirmWorktreeAction`, `archiveWorktreeAction`, `deleteWorktreeAction`, and `visibleHotkeyWorktreeRows`. -- Preserve the `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization currently owned by `SidebarView`. -- Do not regress menu commands or numbered worktree hotkeys when replacing `List(selection:)`. - -### Existing row affordances - -- Preserve repository and worktree context menus. -- Preserve drag previews. -- Preserve current worktree row type-select behavior. Worktree rows currently use `.typeSelectEquivalent("")`; V1 should keep type-select effectively disabled for those rows. -- Preserve root-level `dropDestination(for: URL.self)` on the sidebar container, including drops into blank sidebar space. - -### Ordered roots - -- Converge the current `orderedRoots.isEmpty` fallback and non-empty custom-order path into one presentation path. -- The empty ordered-roots case is a valid user state and must have tests. - -## Proposed Architecture - -### SidebarPresentation - -Introduce a pure presentation model that flattens current repository state into explicit sidebar units. - -Suggested model: - -```swift -struct SidebarPresentation: Equatable { - var items: [SidebarItem] -} - -enum SidebarItem: Equatable, Identifiable { - case listHeader(SidebarListHeaderModel) - case repository(SidebarRepositoryContainerModel) - case failedRepository(FailedRepositoryModel) - case archivedWorktrees(ArchivedWorktreesRowModel) -} - -struct SidebarRepositoryContainerModel: Equatable, Identifiable { - var repositoryID: Repository.ID - var title: String - var rootURL: URL - var kind: Repository.Kind - var isExpanded: Bool - var isRemoving: Bool - var worktreeSections: WorktreeRowSections -} -``` - -Rules: - -- build `SidebarPresentation` from reducer/state-side pure functions or equivalent helpers -- outer `items` contains one item per repository, not one item per row -- worktree sections remain inside the repository container -- presentation construction is pure and unit-tested -- high-frequency terminal notification/task/run-script state stays in leaf views, not in broad presentation state -- Canvas, Shelf, and footer chrome remain outside `SidebarPresentation` in V1 - -### Selection - -Replace `List(selection:)` with explicit selection handling. - -Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions. - -Compatibility matrix: - -| Interaction | State behavior | Focus behavior | -| --- | --- | --- | -| Canvas button | Selects Canvas and clears incompatible sidebar worktree selection. | Does not focus a terminal. | -| Shelf button | Selects Shelf and clears incompatible sidebar worktree selection. | Does not focus a terminal. | -| Archived worktrees row | Selects archived worktrees and clears incompatible worktree selection. | Does not focus a terminal. | -| Git repository header click | Toggles expanded state by default. | Does not focus a terminal. | -| Plain folder repository click | Selects the repository. | Does not focus a terminal unless current behavior already does. | -| Worktree row normal click | Selects one worktree and updates sidebar selected worktree IDs to that one ID. | Focuses the terminal for the selected worktree. | -| Worktree row Cmd-click | Toggles membership in sidebar selected worktree IDs, preserving multi-select priority. | Does not steal focus unless the resulting primary selection changes by existing rules. | -| Empty sidebar selection | Clears sidebar selected worktree IDs. | Does not focus a terminal. | - -Selection visuals should be explicit in `RepositoryHeaderRow` and `WorktreeRow`, not inherited from `List`. - -### Keyboard Navigation - -Preserve the existing command actions first: - -- `selectNextWorktree` -- `selectPreviousWorktree` -- `revealSelectedWorktreeInSidebar` -- numbered hotkeys - -Do not try to rebuild full Finder-like keyboard navigation in the first pass unless it is currently user-visible and relied upon. - -Required V1 behavior: - -- command shortcuts still select worktrees -- selected row is scrolled into view on reveal -- focus returns to terminal after single worktree selection -- sidebar focus does not accidentally forward text while Canvas, Shelf, or Archived rules say it should not - -### Repository Reorder - -Replace `ForEach(...).onMove` with explicit repository drag/drop. - -Suggested approach: - -- make `RepositoryContainerRow` draggable with repository ID payload -- render a custom repository insertion indicator between repository containers -- compute drop destination as a repository index -- dispatch existing repository-ordering actions or a new reducer action that delegates to the same persistence path - -The custom indicator should always render at repository container boundaries: - -```text -Target Repo header - Target Repo worktree -o----------- -``` - -This directly fixes the current downward-drag indicator bug. - -### Worktree Reorder - -Keep worktree reorder scoped inside one repository container. - -Suggested approach: - -- worktree rows are draggable with worktree ID payload -- pinned and unpinned sections keep separate drop zones -- main and pending rows remain non-movable -- drop destination maps to existing reducer actions: - - `.pinnedWorktreesMoved(repositoryID, offsets, destination)` - - `.unpinnedWorktreesMoved(repositoryID, offsets, destination)` - -Cross-repository worktree drag can stay out of scope. The current model does not appear to support moving worktrees between repositories. - -### Drag-Time Freeze - -Add sidebar drag state at reducer level and use it in both the old and new sidebar paths. - -During any sidebar drag: - -- freeze hover-only row actions -- hide pull request / notification popover affordances that resize rows -- suppress row-ID animations caused by notification-driven reorder -- defer "move notified worktree to top" until drag ends, or apply it without animation after drop - -Reducer behavior: - -- drag begin records that sidebar drag is active -- `worktreeNotificationReceived` while drag is active records pending reorder IDs instead of mutating row order immediately -- drag end flushes pending notification reorders in deterministic order, dropping stale worktree IDs -- `moveNotifiedWorktreeToTop == false` remains a no-op - -This addresses #222 without requiring every live data read to stop. - -### Expand / Collapse - -Move expand/collapse animation into `RepositoryContainerRow`. - -Rules: - -- outer repository container identity must not change when worktrees appear/disappear -- single repo expand/collapse animates child rows inside the container -- bulk expand/collapse updates many containers, but the outer stack still has stable repository items -- avoid animating row identity and live status changes in the same transaction - -### Reveal In Sidebar - -`ScrollViewReader.scrollTo` can still work, but scroll IDs must be explicit: - -- repository container: `SidebarScrollID.repository(repositoryID)` -- worktree row: `SidebarScrollID.worktree(worktreeID)` -- archived worktrees row: `SidebarScrollID.archivedWorktrees` - -When revealing a collapsed worktree: - -1. expand its repository -2. wait for an event-driven row availability signal -3. scroll to `SidebarScrollID.worktree(worktreeID)` -4. consume pending reveal - -Do not rely on a fixed number of `Task.yield()` calls in the new architecture. The implementation can use a scroll target registry, preference key, or equivalent view materialization signal. - -### Accessibility - -Minimum accessibility requirements: - -- repository headers expose button/row labels and expanded state -- worktree rows expose selection state -- drag handles or rows expose reorder affordance where AppKit/SwiftUI can support it -- Canvas / Shelf / Archived rows keep meaningful labels - -If full native `List` accessibility cannot be matched in V1, document the gap and keep keyboard command coverage strong. - -## Implementation Plan - -### Phase 0: Baseline and Guardrails - -- Add a short manual repro checklist for: - - repository drag up/down over expanded target - - bulk expand/collapse with many repositories - - worktree reorder in pinned/unpinned groups - - sidebar multi-selection - - reveal-in-sidebar -- Add signposts around sidebar presentation build and drag state transitions if trace work is needed. -- Establish `LazyVStack` vs `VStack` decision metrics before replacing the list: - - expand/collapse latency for 10+ repositories - - frame stability during repository drag - - CPU peak during drag and bulk expand/collapse - - body recomputation count for repository container and worktree row views -- Keep current `List` code untouched until M1 and presentation tests exist. - -### M1: Stabilize Old `List` Drag Behavior - -Files likely involved: - -- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` -- `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift` -- `supacode/Features/Repositories/Views/SidebarListView.swift` -- `supacodeTests/RepositoriesFeatureTests.swift` - -Deliver: - -- reducer-level sidebar drag state -- view action for drag begin/end from the old `List` path -- delayed or no-animation handling for notification-driven reorder during drag -- deterministic pending reorder flush on drag end - -Tests: - -- notification during sidebar drag does not mutate visible worktree order immediately -- drag end applies the pending notification reorder in deterministic order -- multiple notifications during one drag produce stable ordering -- stale pending worktree IDs are ignored -- `moveNotifiedWorktreeToTop == false` remains a no-op -- persistence is called only when the reorder is actually applied - -### Phase 1: Pure Presentation and Reorder Mapping - -Files likely involved: - -- `supacode/Features/Repositories/Models/SidebarPresentation.swift` (new) -- `supacodeTests/SidebarPresentationTests.swift` (new) -- existing reducer ordering tests - -Deliver: - -- pure sidebar presentation builder -- stable scroll IDs -- pure drop-destination mapping for repository and worktree reorder -- one unified presentation path for empty and non-empty ordered roots -- explicit failed repository row reorder semantics - -Tests: - -- expanded repository keeps one outer item with child rows -- failed repositories preserve the chosen reorder semantics -- plain folders produce repository containers with no worktree children -- pinned/main/pending/unpinned sections are preserved -- empty ordered roots and custom ordered roots produce equivalent presentation rules -- repository drop destinations map to expected order -- worktree drop destinations map within pinned/unpinned sections - -### Phase 2: New Container Views Behind a Switch - -Files likely involved: - -- `SidebarListView.swift` -- `RepositorySectionView.swift` -- `WorktreeRowsView.swift` -- new `SidebarContainerListView.swift` -- new `RepositoryContainerRow.swift` - -Deliver: - -- render the new container sidebar behind a local compile-time or private runtime switch -- no reducer changes except new presentation helpers if needed -- preserve row styling visually before enabling custom drag/drop -- preserve root-level URL drop for files dragged into blank sidebar space -- preserve context menus and drag previews - -This phase should be screenshot/manual verified before deleting the old `List` path. - -### Phase 3: Explicit Selection, Focus, and Reveal - -Deliver: - -- click handling for repository and worktree rows -- explicit selection visuals -- multi-selection behavior matching the compatibility matrix -- `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization -- focused actions and hotkey row values -- reveal-in-sidebar via new scroll IDs and row availability events -- focused terminal handoff after single worktree selection - -Tests: - -- pure selection helper tests -- reducer tests for sidebar selected worktree synchronization -- focused action manual checklist for confirm/archive/delete and numbered hotkeys - -### Phase 4: Custom Repository Reorder - -Deliver: - -- repository drag payload -- custom repo-level insertion indicator -- drop handling that dispatches repository reorder through the existing persistence path -- drag-time UI freeze for non-essential row affordances - -Manual verification: - -- dragging a repository upward shows indicator below the target repository container when appropriate -- dragging a repository downward never shows the indicator between target header and target worktree rows -- failed repository rows follow the documented reorder semantics - -### Phase 5: Custom Worktree Reorder - -Deliver: - -- pinned/unpinned scoped worktree drop zones -- custom worktree insertion indicator -- main/pending rows stay non-movable -- existing persistence paths remain unchanged - -Manual verification: - -- pinned worktree reorder persists -- unpinned worktree reorder persists -- dragging over main/pending rows does not create invalid moves - -### Phase 6: Remove Old `List` Path and Polish - -Deliver: - -- delete old `List(selection:)` implementation -- remove obsolete `RepositorySectionView` / `WorktreeRowsView` pieces or fold them into new components -- final accessibility pass -- final animation pass for bulk expand/collapse -- update issue #249 with final implementation notes - -## Verification Matrix - -Automated: - -- `SidebarPresentationTests` -- reducer tests for sidebar drag gate and notification reorder concurrency -- reducer tests for expanded/collapsed state write-back and invalid collapsed ID cleanup -- reducer tests for sidebar selected worktree synchronization -- existing `RepositoriesFeatureTests` ordering tests -- existing `RepositorySectionViewTests` migrated or renamed -- `make check` -- `make build-app` - -Manual: - -1. Select a plain folder repository row. -2. Click a git repository header and confirm it expands/collapses without selecting a worktree. -3. Select a git repository worktree row and confirm terminal focus. -4. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows. -5. Verify confirm/archive/delete menu commands target the same worktrees as before. -6. Verify numbered worktree hotkeys use visible sidebar rows. -7. Expand/collapse one repository. -8. Bulk expand/collapse at least 10 repositories. -9. Drag repository upward and downward across expanded repositories. -10. Drag pinned worktrees within a repository. -11. Drag unpinned worktrees within a repository. -12. Trigger reveal-in-sidebar from Canvas or command. -13. Verify Canvas / Shelf / Archived interactions remain correct. -14. Verify notification/task/run-script indicators update without moving rows during a drag. -15. Drop a repository URL onto a visible row and onto blank sidebar space. -16. Verify repository and worktree context menus. -17. Verify drag previews. -18. Verify worktree rows do not gain type-select behavior in V1. - -## Risks - -### Native `List` behavior loss - -Risk: custom scroll rows may lose some free AppKit sidebar behavior. - -Mitigation: - -- preserve command-based navigation first -- add explicit accessibility labels/traits -- keep manual keyboard/accessibility checklist - -### Reorder implementation complexity - -Risk: custom drag/drop can become more complex than native `.onMove`. - -Mitigation: - -- keep pure drop-index mapping tested -- keep repository reorder and worktree reorder separate -- defer cross-repository worktree moves - -### UI regressions from broad rewrite - -Risk: replacing the sidebar in one PR touches selection, animation, and drag. - -Mitigation: - -- stage behind a private switch until visual behavior is verified -- land M1 and presentation model tests first -- keep reducer actions and persistence shape stable - -### Performance regressions - -Risk: replacing lazy `List` with `VStack` could render too much. - -Mitigation: - -- start with `LazyVStack` -- switch only repository containers to non-lazy child stacks if expand/collapse animation needs it -- decide using the Phase 0 metrics rather than visual impression alone - -## Recommendation - -Proceed with Option B as the #249 plan: a custom `ScrollView` sidebar with repository containers as outer items. - -Do not attempt to fix the repository insertion indicator through reducer index changes. The indicator is a symptom of the current `List` row structure, not the persisted ordering logic. - -The safest execution path is: - -1. baseline metrics and manual guardrails -2. M1 old `List` drag gate and reducer concurrency tests -3. pure presentation model and tests -4. render-only new sidebar path -5. explicit selection, focus, and reveal -6. custom repository reorder -7. custom worktree reorder -8. remove old `List` path - -This is larger than a tactical #222 fix, but it addresses the underlying sidebar design mismatch and gives future sidebar features a cleaner foundation. diff --git a/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md b/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md deleted file mode 100644 index e7b69e18..00000000 --- a/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md +++ /dev/null @@ -1,707 +0,0 @@ -# Active Agents Panel - -## Context - -Prowl 当前对"agent 在不在跑"的检测很弱:状态只有 `idle` / `running` 两态(`supacode/Domain/WorktreeTaskStatus.swift`),粒度是 per-worktree,唯一信号源是 Ghostty 的 progress state(OSC 序列)。这意味着 (a) shell integration 缺失或 agent 不上报 progress 时完全识别不到;(b) 同一 tab 内多个 split pane 各跑一个 agent 时无法区分;(c) 没有 `blocked`(agent 等用户输入)状态;(d) 没有跨 worktree 的 agent 全局视图。 - -目标是新增 **Active Agents** 面板: - -- 位于左侧 sidebar 底部、worktree `LazyVStack` 下方 -- 高度可拖拽,可通过 footer 按钮折叠/展开 -- 折叠时面板从底部消失,展开时**从底部滑入**(带动画) -- 列出**所有** worktree/tab/pane 中正在运行的 agent,状态分四档:working / blocked / done(unread idle) / idle -- 点击跳转到对应 worktree → tab → pane(surface) - -参考实现是 [herdr](https://github.com/ogulcancelik/herdr) (Rust, ratatui)。本计划深度借鉴其 process detection + screen heuristics 混合检测算法,并改造为 Swift / GhosttyKit 适配的形态。 - -实施分两个阶段:**Phase 1 重写 agent 检测(核心,决定整个特性是否靠谱)**,**Phase 2 UI 与接线**。 - ---- - -## 关于 Ghostty fork(需要一个新决定) - -**现状**: - -- `ThirdParty/ghostty` 是 submodule,URL 指向 **upstream** `ghostty-org/ghostty`,目前锁在 tag `v1.3.1` (commit `332b2aef`) -- **没有 onevcat fork**,**没有任何本地 patch**(`change-list.md` 历来只记录 supacode 那边的同步,从未碰过 Ghostty 源码) -- Prowl 用 `make build-ghostty-xcframework` 在本地从 Zig 源码构建产物 `Frameworks/GhosttyKit.xcframework` - -**需要的改动**:暴露 `ghostty_surface_pid()` C 导出(≤ 30 行 Zig 代码)。这是 Phase 1 的硬前置——没 PID 就没 herdr-style process detection。 - -| 方案 | 描述 | 优 | 劣 | -|---|---|---|---| -| **建 `onevcat/ghostty` fork + per-version patched 分支** *(选定)* | fork 仓库;每个上游 tag 创建一条独立的 `release/vX.Y.Z-patched` 分支,把 onevcat 的 patches 应用在该 tag 之上;submodule pin 到对应分支的 HEAD commit | 每个版本可追溯(不重写历史);patches 可在分支间 cherry-pick;与 OpenClaw 派生但更适合 Ghostty 这种节奏稳定的发布 | 每次升级要新建分支 + cherry-pick;分支会随版本累积(接受) | - -**分支命名**:`release/v-patched`,例如 `release/v1.3.1-patched`、`release/v1.3.2-patched`。每条分支的"基底"是对应上游 tag,"附加" commits 都是 onevcat 的 patches(如 `ghostty_surface_pid` 导出)。 - -**新增文档**:`doc-onevcat/fork-sync-ghostty.md`,描述升级到新上游 tag 的流程: - -```bash -# 在 ThirdParty/ghostty 内(首次需 git remote add onevcat git@github.com:onevcat/ghostty.git) -cd ThirdParty/ghostty -git fetch upstream --tags -git fetch onevcat - -PREV=v1.3.1 -NEXT=v1.3.2 - -# 1. 从新 upstream tag 拉一条 patched 分支 -git checkout -b "release/${NEXT}-patched" "${NEXT}" - -# 2. 把上一条 patched 分支相对其基底 tag 多出来的 commits cherry-pick 过来 -# "${PREV}..onevcat/release/${PREV}-patched" 选出 = patches -git cherry-pick "${PREV}..onevcat/release/${PREV}-patched" - -# 3. 推到 fork(首次推新分支,不需要 force) -git push -u onevcat "release/${NEXT}-patched" - -# 4. (回到 Prowl 主仓库)更新 submodule 指针 -cd ../.. -git -C ThirdParty/ghostty checkout "release/${NEXT}-patched" -git add ThirdParty/ghostty -git commit -m "ghostty: bump submodule to ${NEXT}-patched" - -# 5. 重建 GhosttyKit -make build-ghostty-xcframework -``` - -**关于 force push**:per-version 分支模式下,patched 分支**只在新建时推一次**,之后不重写历史;所以不需要 `--force` / `--force-with-lease`。如果 cherry-pick 出错需要修补,先在 `release/${NEXT}-patched-fix` 分支调整,验证 OK 再 fast-forward 到 `release/${NEXT}-patched` 推上去。 - ---- - -## Phase 1 — Detection Layer Rewrite - -### 1.1 设计原则(来自 herdr,验证过靠谱) - -> herdr `INTEGRATIONS.md`: "process detection owns pane identity, liveness, and 'the process is gone'; screen heuristics remain the fallback for state." - -**三层职责切分**: - -- **Process detection** 决定 *身份与存活*("这个 pane 是不是有 agent / 是哪个 agent / 还在不在") -- **Screen heuristics** 决定 *fallback state*(working / blocked / idle) -- **Hook/integration**(**Phase 1 暂不做**):未来可让 Claude/Codex hook 通过 socket 上报权威状态,Phase 1 完全不依赖 - -**四档状态机**: - -``` -检测器内部: AgentRawState = { working, blocked, idle, unknown } -UI 显示: DisplayState = { working, blocked, done, idle } -``` - -- `done` 是派生:`state == idle && seen == false` -- `seen` 在 surface 被聚焦/可见时翻 true,在 `working|blocked → idle` 转移且当时不在前台时翻 false - -### 1.2 暴露 Ghostty surface 的 child PID - -**问题**:当前 GhosttyKit C API 没导出 surface 的 child process PID。Zig 内部 `termio.Exec.cmd.pid` 是有的(`ThirdParty/ghostty/src/termio/Exec.zig:1136`,`ThirdParty/ghostty/src/Surface.zig:140-142` 已有 `child_exited` 标志位证明 surface 持有这条信息)。 - -**改动**(在 `onevcat/ghostty` fork 的 `release/v1.3.1-patched` 分支上做;后续 Ghostty 升级时新建对应版本号的 `release/v-patched` 分支并 cherry-pick): - -- `src/Surface.zig`:新增 `pub fn getChildPid(self: *Surface) ?std.posix.pid_t`,从 surface 持有的 `Termio.Exec` 中读出 `cmd.pid` -- `src/apprt/embedded.zig`:紧贴现有 `ghostty_surface_process_exited` (line 1082 in header) 后面新增 `export fn ghostty_surface_pid(surface: ?*Surface) c_int`,返回 0 表示未知或已退出 -- `include/ghostty.h`(生成):新增声明 -- 通过 `make build-ghostty-xcframework` 重建 xcframework - -### 1.3 macOS process detection helpers - -**新文件**:`supacode/Infrastructure/AgentDetection/ProcessDetection.swift` - -逐条移植 [`herdr/src/platform/macos.rs`](https://github.com/ogulcancelik/herdr/blob/master/src/platform/macos.rs) 算法到 Swift,使用 Darwin C 接口: - -| 功能 | herdr Rust | Swift 实现 | -|---|---|---| -| 取 pty foreground PGID | `proc_pidinfo(pid, PROC_PIDTBSDINFO, …)` 读 `e_tpgid` | 同 syscall (`Darwin.proc_pidinfo`, `proc_bsdinfo`) | -| 列所有 PID | `proc_listallpids` | 同 | -| 过滤 fg group | 比较 `pbi_pgid == fg_pgid` | 同 | -| 取 argv[0] (catch `process.title="pi"`) | `sysctl(KERN_PROCARGS2)` 解析 | 同 (`Darwin.sysctl` with `[CTL_KERN, KERN_PROCARGS2, pid]`) | -| 取短名 fallback | `pbi_comm` | 同 | - -**输出 struct**:`ForegroundJob { processGroupID: pid_t, processes: [ForegroundProcess] }`,`ForegroundProcess { pid, name, argv0?, cmdline? }` - -### 1.4 Agent classifier - -**新文件**:`supacode/Infrastructure/AgentDetection/AgentClassifier.swift` - -跟 herdr 完全对齐,**初版即支持 herdr 列表的全部 11 个**(pi, claude, codex, gemini, cursor, cline, opencode, copilot, kimi, droid, amp): - -```swift -enum DetectedAgent: String, CaseIterable { - case pi, claude, codex, gemini, cursor, cline - case opencode, copilot, kimi, droid, amp -} - -func identifyAgent(processName: String) -> DetectedAgent? -func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: String)? -``` - -`identifyAgentInJob` 复刻 herdr 的 wrapped-runtime 逻辑:如果前台进程名是 `node`/`bun`/`python`/`sh`/`bash`/`zsh`/`fish`/`tmux` 等通用 runtime,扫 `cmdline` 里 token 的 basename 找 agent 名(`node /path/to/codex` → `Codex`);priority scoring 选最佳候选。 - -后续要支持新 agent,只需要 (a) 加 enum case (b) 在 `identifyAgent` 加映射 (c) 写 detector + 测试。 - -### 1.5 Screen heuristics — per-agent detectors - -> 这一节详细解释每个状态怎么从屏幕文本判定。下面分两部分:先讲整体架构与每个状态的判定规则,再走一个具体例子。 - -#### 1.5.1 输入:viewport text - -**怎么拿屏幕内容**:复用现有 C API `ghostty_surface_read_text`(已在 `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift:664` 用过 with selection): - -- 用 `ghostty_point_s { tag: GHOSTTY_POINT_VIEWPORT, coord: TOP_LEFT, x:0, y:0 }` 起,到 viewport 右下角 -- 这给我们当前可见行的纯文本(应该是最后 N 行,N = surface 高度,~30-50 行) -- 这就对应 herdr 的 `terminal.detection_text()` -- 封装成 `GhosttySurfaceBridge.readViewportText() -> String?` - -#### 1.5.2 整体策略:每个 agent 一个独立 detector - -```swift -func detectState(agent: DetectedAgent?, screen: String) -> AgentRawState { - guard let agent = agent else { return .unknown } - switch agent { - case .claude: return detectClaude(screen) - case .codex: return detectCodex(screen) - case .gemini: return detectGemini(screen) - // ... 每个 agent 一个 - } -} -``` - -每个 detector 是一个**纯函数** `(String) -> AgentRawState`,按优先级查 blocked → working → 默认 idle。**完全可单测,不需要 Ghostty / pty / async**。 - -#### 1.5.3 具体规则(直接来自 herdr,已被它的测试集验证) - -每条规则都是"屏幕里出现某段特定 UI 文字"。这些字符串是 agent CLI 自己渲染的("esc to interrupt"、"❯ Yes"、"approve?" 等),所以非常稳定。一旦 agent 升级 UI 文案,detector 就要更新——**这是已知的维护成本,跟 herdr 一样接受**。 - -##### Claude Code(最复杂的一个) - -Claude 的 UI 是个结构化 prompt box: - -``` - (agent 输出 / 工具结果) - ───────────────────── ← 上边框 - ❯ _ ← 输入行 - ───────────────────── ← 下边框 -``` - -判定优先级: - -1. **Blocked**: 内容里出现 `"do you want"` 或 `"would you like"`,且后面跟 `"yes"` 或 `❯`;或显式的 `"do you want to proceed?"` / `"waiting for permission"` / `"do you want to allow this connection?"`;或 (selection prompt + yes/no choice) 组合 -2. **Working**: 把 prompt box **上面**的内容单独抽出来(用边框定位,避免误把上次的 `esc to interrupt` 当成本次状态),如果上面那段含 `"esc to interrupt"` / `"ctrl+c to interrupt"`,或行首是 spinner glyph (`✱✲✳✴…` 或 `·` 中点) 后跟 `"…"` (U+2026) -3. **否则 idle** - -具体例子(直接是 herdr 测试 fixture): - -``` -✽ Tempering… -───────── -❯ -───────── -``` - -→ 行首 `✽` 是 spinner,后接 `…`,认定 **working** - -``` -Do you want to proceed? -❯ 1. Yes - 2. No - -Esc to cancel · Tab to amend -``` - -→ 含 `"do you want to proceed?"` + `❯` 跟数字选项,认定 **blocked** - -``` -Task complete. -───────────── -❯ -───────────── -``` - -→ 上面段无 spinner、无 interrupt 文字,认定 **idle** - -##### Codex - -判定(更平铺直叙,没结构化 box): - -1. **Blocked**: `"press enter to confirm or esc to cancel"` / `"enter to submit answer"` / `"allow command?"` / `"[y/n]"` / `"yes (y)"` 之一 -2. **Working**: `"esc to interrupt"` / `"ctrl+c to interrupt"`,或行首 `•` 后跟 `"Working ("`(codex 自己的状态行 header) -3. **否则 idle** - -##### 其他 agent - -- **Gemini**: blocked = `"waiting for user confirmation"` 或 box 字符 `│` 起头 + `"Apply this change"` / `"Allow execution"` / `"Do you want to proceed"`;working = `"esc to cancel"` -- **Cursor**: blocked = `"(y) (enter)"` / `"keep (n)"` / 含 `"(y)"` + (`"allow"` 或 `"run"`);working = `"ctrl+c to stop"` 或行首 `⬡⬢` + 含 `"ing"` 字(cursor 的 spinner) -- **Cline**: blocked = `"let cline use this tool"` 或 `[act mode]/[plan mode]` + `"yes"`;idle = `"cline is ready for your message"`;**注意 cline 默认是 working**(不像别的默认 idle),因为 cline 长时间执行不显式上报 -- **OpenCode**: blocked = `"△ Permission required"` 或问题菜单 (`↑↓ select` + `Enter confirm/submit/toggle` + `Esc dismiss`);working = `"esc to interrupt"` -- **Copilot (`ghcs`)**: blocked = `"│ do you want"` 或 `"confirm with ... enter"`;working = `"esc to cancel"` -- **Kimi**: blocked = `"allow?"` / `"confirm?"` / `"approve?"` / `"proceed?"` / `"[y/n]"` / `"(y/n)"`;working = `"thinking"` / `"processing"` / `"generating"` / `"waiting for response"` / `"ctrl+c to cancel"` -- **Droid**: blocked = `"EXECUTE"` 关键字 + 选择 chrome (`"enter to select"` / `"↑↓ to navigate"`);working = 行首 braille spinner (U+2800-28FF) + `"esc to stop"` -- **Amp**: blocked = `"approve"` 选项 + `"allow all for this session"` 等组合 + (`"waiting for approval"` 或 `"invoke tool"` / `"run this command?"` 等 header);working = `"esc to cancel"` -- **Pi**: working = `"Working..."`;其他 idle(最简单) - -#### 1.5.4 单 tick 完整流程(带具体例子) - -假设开了一个 pane,跑了 `claude`,让它读个文件。看一次 detection tick 干了什么: - -``` -t=0: [process probe] proc_pidinfo(panePid).e_tpgid → fgPgid=12345 - proc_listallpids 过滤出 pgid=12345 → [{pid:12345,name:"node",cmdline:"node /usr/local/bin/claude"}] - identifyAgentInJob → DetectedAgent.claude - AgentDetectionPresence: current=Claude (新识别) - [screen heuristic] viewport text: - "Reading file src/main.rs - ✽ Pondering… (esc to interrupt) - ───────── - ❯ - ─────────" - detectClaude: - - 不含 do_you_want → 不 blocked - - content_above_prompt_box() 切到 "Reading file..." + "✽ Pondering..." - - 含 "esc to interrupt" → working - stabilize_agent_state(Claude, prev=unknown, raw=working) → working - 发出 .agentStateChanged(surfaceID, agent: claude, state: working) - -t=300ms: 同样流程,仍 working - -t=2.4s: Claude 完成读取,UI 变成 - "Read 1245 lines - ───────── - ❯ - ─────────" - detectClaude: 上面段无 spinner、无 interrupt → raw=idle - stabilize: previous=working, raw=idle, 距离上次 last_claude_working_at < 1.2s → 仍返回 working (粘滞窗口防抖) - -t=3.5s: 同样 idle,但已超过 1.2s 粘滞窗口 → idle - 发出 .agentStateChanged(state: idle) - - 此时如果 surface 不在前台 → seen=false → UI 显示 "done" - 否则 seen=true → UI 显示 "idle" - -(用户跟 Claude 说 "rm -rf /tmp/test") -t=10s: viewport: - "Allow bash: rm -rf /tmp/test? - - Do you want to proceed? - - ❯ 1. Yes - 2. No - - esc to cancel" - detectClaude: - - has_claude_blocked_prompt 命中 "do you want to proceed?" → blocked - 发出 .agentStateChanged(state: blocked) - seen 立即翻 true (blocked 不算"完成",要醒目) - -t=15s: 用户点 1 (yes),Claude 又开始干活 - viewport 重新出现 "esc to interrupt" → working -``` - -**轮询频率**: - -- agent 已识别:300ms tick -- agent 未识别:500ms tick -- "pending release" 期:50ms tick(agent 刚退出后短暂窗口,避免漏掉重新启动) -- Process probe 节流:5s 一次(除非满足"立即检查"条件:当前无 agent / fg PGID 变了 / 有 pending release) - -**Agent 退出**:连续 6 次 process probe miss 才清掉 detected agent(约 1.8s @ 300ms tick),防止瞬时误读。 - -#### 1.5.5 多语言策略 - -**问题**:Screen heuristics 依赖匹配 agent CLI 渲染的 UI 文字。如果 agent 把 UI 本地化(中文 / 德文 / 日文 / ...),detector 会失效。 - -**现状盘点**(onevcat 实测过的 11 个 agent): - -| Agent | UI 是否本地化 | 风险等级 | -|---|---|---| -| Claude Code | 否,UI string 在 cli.js 里硬编码英文 | 极低 | -| Codex | 否,硬编码英文 | 极低 | -| Gemini CLI | 否 | 极低 | -| Cursor CLI | 否 | 极低 | -| Cline | 否(VS Code 扩展为主) | 极低 | -| OpenCode | 否 | 极低 | -| GitHub Copilot CLI | 否 | 极低 | -| **Kimi** | **可能是**——Moonshot 的中文优先 agent,部分 footer / prompt 可能是中文 | **真实风险** | -| Droid | 否 | 极低 | -| Amp | 否 | 极低 | -| Pi | 否 | 极低 | - -模型对话内容当然是多语言的,但 detector 看的是 **agent CLI 的 UI chrome**("esc to interrupt"、"Do you want to proceed?"、"[y/n]"),这些 99% 是英文常量。 - -**信号天然分两类**: - -- **A. Language-neutral signals**(无视语言,最稳): - - Spinner glyph:`✱✲✳✴✵`(Claude)、`⬡⬢`(Cursor)、`⠋⠙⠹⠸`(Droid braille)、`✽` 等 - - Box drawing chars:`─ │ ❯ ⌕`(Claude prompt box / Gemini `│ Apply` 等) - - Control keys:`esc`, `ctrl+c`, `enter`, `tab` —— 即便本地化也保持英文(标准 CLI 惯例) - - Symbols:`[y/n]`, `(y/n)`, `→ ↑ ↓`, `?`, ellipsis `…` - - 数字选项:`1.` `2.` `3.` -- **B. English-text signals**(最常见但易被本地化击穿):`"esc to interrupt"`、`"do you want to proceed"`、`"approve?"`、`"thinking"`、`"waiting for"` 等多词短语 - -**Phase 1 实装策略——分层防御**: - -1. **每个 detector 内部,A 类信号优先级抬高** - - working / blocked 判定用 `(A) OR (B)`,A 命中即不再看 B - - 例:Claude working = `行首 spinner glyph`(A)OR `"esc to interrupt"`(B) - - 例:blocked = `(selection prompt + ❯/数字选项)`(A 组合)OR `"do you want to proceed?"`(B) - - 这种"或"组合本来就在 herdr 里大量出现,A 类能命中的场景保留语言无关性 - -2. **Detector 注释里标注每条规则的类别** - - ```swift - // language-neutral: spinner glyph at line start - if hasSpinnerActivity(above) { return .working } - // english-only: tool footer hint - if aboveLower.contains("esc to interrupt") { return .working } - ``` - - 将来某 agent 突然本地化时,能一眼定位哪条规则要扩。 - -3. **Kimi 单独留一个 multi-pattern 接口** - - ```swift - func detectKimi(_ content: String) -> AgentRawState { - let blockedPatterns: [String] = [ - "allow?", "confirm?", "approve?", "proceed?", - "[y/n]", "(y/n)", - // 中文待 onevcat 跑实例后补充: "允许?", "确认?", ... - ] - // ... - } - ``` - - 等 onevcat 实际跑 Kimi 抓到 viewport sample 再补中文 pattern。其他 agent 维持纯英文。 - -**Phase 1 不做但 Phase 3 应该做**: - -- **Hook integration(herdr 也走这条路)**:Claude / Codex / OpenCode 都暴露了 hook,可让它们直接通过 socket 上报 `working/blocked/idle` **语义状态**——完全无视 UI 文字。这是治本方案,但 Phase 1 范围外。 - -### 1.6 Per-pane state machine + stabilization - -**新文件**:`supacode/Domain/AgentDetection/PaneAgentState.swift` - -复刻 herdr [`pane/state.rs`](https://github.com/ogulcancelik/herdr/blob/master/src/pane/state.rs) 的 `PaneState`: - -```swift -struct PaneAgentState { - var detectedAgent: DetectedAgent? - var fallbackState: AgentRawState - var state: AgentRawState // = fallback (Phase 1 没 hook authority) - var seen: Bool = true - var lastChangedAt: Date // 用于 UI 排序 -} - -// 抖动控制 -struct AgentDetectionPresence { - var currentAgent: DetectedAgent? - var consecutiveMisses: UInt8 // 6 次连续 miss 才清 -} - -func stabilizeAgentState( - agent: DetectedAgent?, - previous: AgentRawState, - raw: AgentRawState, - now: Date, - lastClaudeWorkingAt: inout Date? -) -> AgentRawState -``` - -`stabilizeAgentState` 关键逻辑(仅对 Claude):working → idle 转移有 **1.2s 粘滞窗口** (`CLAUDE_WORKING_HOLD`),防止 tool result 渲染瞬间被误读为 idle。其他 agent 直接透传 raw。 - -### 1.7 Process syscall smoke test(**Phase 1 第一个动作**) - -**前置事实**:Prowl **没开 App Sandbox**(`ENABLE_APP_SANDBOX = NO` in `supacode.xcodeproj/project.pbxproj`,`supacode.entitlements` 也无 `com.apple.security.app-sandbox`)。Hardened runtime + notarization 不限制 `proc_listallpids` / `proc_pidinfo` / `sysctl(KERN_PROCARGS2)` 这类只读 syscall。 - -所以这一步**不是验证 sandbox**,只是常规 smoke test 确认调用方式正确、数据格式符合预期: - -1. 在 Debug 构建里写一个 ~50 行的小测试:spawn 一个 shell,跑 claude,调上面三个 syscall 抓一帧数据 dump 出来比对预期。直接放在 `supacodeTests/Spikes/ProcessDetectionSpikeTests.swift` 跑一次扔掉 -2. 验证 `proc_pidinfo` 返回的 `e_tpgid` 跟独立 `ps` 命令的结果一致 -3. 验证 `KERN_PROCARGS2` 解析能正确拿到 `argv[0]`(比如 node spawn 的 claude 应该能看到 `claude` 而不只是 `node`) - -通过即开始正式实装;任何 syscall 报错(不太可能)才需要重新评估。 - -### 1.8 Wiring into existing model - -**修改文件**: - -- `supacode/Domain/WorktreeTaskStatus.swift` — 不动现有 enum;引入并行的新模型 `AgentRawState` 在 `AgentDetection/` 下 -- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — 新增 `surfaceAgentStates: [GhosttySurfaceID: PaneAgentState]`,在 surface 创建/关闭时启停 detection task -- `supacode/Clients/Terminal/TerminalClient.swift` — `Event` 新增 `.agentStateChanged(worktreeID:surfaceID:state:agent:)` 和 `.agentSeenChanged(...)` -- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — 转发新事件 -- 复用现有 `onTaskStatusChanged` 的派发模式(`WorktreeTerminalManager.swift:248`) - -**新建顶层 registry**:`supacode/Features/ActiveAgents/Models/ActiveAgentsRegistry.swift`,`@MainActor @Observable class`,订阅所有 worktree 的 agent state 事件,维护跨 worktree 的扁平 `[ActiveAgentEntry]`,给 reducer 读。 - -### 1.9 测试策略 - -- **Unit test 全覆盖**:`ScreenHeuristics` + `AgentClassifier` + `PaneAgentState.stabilize` — 全是纯函数 / 纯数据,**直接移植 herdr `detect.rs` 测试段(约 700 行)的所有 fixture**。每个 detector 都有 working/blocked/idle 样本 -- **Integration test**:`PaneAgentState` 的 detection loop 跑在 `TestClock` 上,喂假的 viewport text + 假的 ForegroundJob,断言状态转移 -- **Manual smoke**:跑 claude/codex 各开一个 pane,肉眼验证 working ↔ blocked ↔ idle 切换 -- **不写自动化 e2e**——对 Ghostty 真 pty 跑端到端的成本太高,靠手工冒烟覆盖 - ---- - -## Phase 2 — UI & Wiring - -### 2.1 Layout(支持从底部滑入动画) - -**核心问题**:希望 footer 按钮一点,面板**从底部滑出**带动画。SplitView 在 hidden/visible 之间切换会重建视图层级,动画会跳。 - -**方案**:始终用 VStack;面板用条件 `if !isHidden` 渲染并配 `.transition(.move(edge: .bottom))`;resize handle 是 panel 自带的顶边 drag bar,不依赖 SplitView。 - -```swift -// SidebarListView 改造后 -ZStack(alignment: .bottom) { - VStack(spacing: 0) { - // 上半:worktree 列表,吃掉剩余高度 - ScrollView { LazyVStack { repositoryItems… } } - .scrollIndicators(.never) - .frame(maxHeight: .infinity) - - // 下半:Active Agents 面板(含顶部 resize handle) - if !isPanelHidden { - ActiveAgentsPanel(store: …) - .frame(height: panelHeight) // 用户拖拽时变化 - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } -} -.animation(.spring(response: 0.4, dampingFraction: 0.85), value: isPanelHidden) -.safeAreaInset(.bottom) { SidebarFooterView(...) } -.clipped() // 防止 transition 期间溢出 sidebar 边界 -``` - -`ActiveAgentsPanel` 内部: - -```swift -VStack(spacing: 0) { - // 顶边 drag handle - Rectangle() - .fill(.separator) - .frame(height: 1) - .overlay(Color.clear.frame(height: 6)) // 点击/拖拽热区 - .contentShape(Rectangle()) - .gesture( - DragGesture() - .onChanged { v in - panelHeight = clamp(panelHeight - v.translation.height, 120, maxAllowed) - } - ) - .onHover { hovering in NSCursor.resizeUpDown.set() } // 视觉反馈 - - // 标题栏 + 列表 - Text("Active Agents") - .font(.caption).foregroundStyle(.secondary) - .padding(.horizontal, 12).padding(.top, 8) - ScrollView { - LazyVStack(spacing: 0) { - ForEach(entries) { entry in - ActiveAgentRow(entry: entry).onTapGesture { … } - } - } - } -} -``` - -**与 SplitView 的对比**: - -- SplitView 现有组件依赖两侧都存在 + 拖动 divider,无法很好处理"右侧/下侧消失"的动画过渡 -- 用 transition + 自带 drag handle 更适合这种 "show/hide with slide-up" 场景 -- 缺点:失去 SplitView 的"双击均分"快捷功能;不重要 - -**持久化**: - -- `@Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false` -- `@Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200` - -### 2.2 TCA feature - -**新文件**:`supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift` - -```swift -@Reducer struct ActiveAgentsFeature { - @ObservableState struct State: Equatable { - var entries: IdentifiedArrayOf = [] - @Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false - @Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200 - } - enum Action { - case task // 启动时订阅 registry - case agentEntriesUpdated([ActiveAgentEntry]) - case entryTapped(ActiveAgentEntry.ID) - case togglePanelVisibility - case panelHeightChanged(Double) - } -} -``` - -挂载位置:作为 `RepositoriesFeature` 的子 reducer(`var activeAgents: ActiveAgentsFeature.State` + `Scope { state: \.activeAgents, action: \.activeAgents }`)。Sidebar 范畴内,不需要爬到 AppFeature。 - -### 2.3 Active Agents row UI - -**新文件**:`supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift` - -布局: - -``` -[icon] agent name [status pill] - worktree · tab · pane -``` - -- icon:复用 `CommandIconMap` 的 `TabIconSource`(`supacode/Features/Terminal/Models/CommandIconMap.swift`) -- agent name:`.body.monospaced()` -- 副标题:`.caption.foregroundStyle(.secondary)`,格式 `worktree-name · tab-N · pane-N` -- status pill:颜色严格走 system color(CLAUDE.md 强制) - - blocked → `.red` - - working → 旋转中的 spinner + `.orange`/`.yellow` - - done → `.blue`(亮,提示未读) - - idle → `.secondary`(灰) - -排序(在 reducer 里算):blocked → working → done → idle,组内按 `lastChangedAt` 倒序。 - -空态:`Text("No active agents").font(.caption).foregroundStyle(.secondary)` 居中。 - -### 2.4 Footer hide toggle - -**修改**:`supacode/Features/Repositories/Views/SidebarFooterView.swift` - -在现有 `HStack` 里(archive / refresh / settings 旁边)加一个按钮: - -```swift -Button { - store.send(.activeAgents(.togglePanelVisibility)) -} label: { - Image(systemName: isHidden ? "rectangle.bottomthird.inset" : "rectangle.bottomthird.inset.filled") -} -.help(isHidden ? "Show Active Agents" : "Hide Active Agents") -``` - -(CLAUDE.md "Buttons must have tooltips") - -### 2.5 Click-to-focus - -新增 TerminalClient 命令 `.focusSurface(worktreeID:tabID:surfaceID:)`: - -1. `setSelectedWorktreeID(worktreeID)` — 切换 worktree(已有) -2. 切到对应 `tabID`(`TerminalTabManager` 里有 `selectedTabID`,扩展为带 surface 参数) -3. 在 split tree 里把焦点设到那个 surface(调用 Ghostty focus + `selectedSurfaceID` 更新) - -reducer 流:`entryTapped(id)` → 找 entry 的 `(worktreeID, tabID, surfaceID)` → `terminalClient.send(.focusSurface(...))` → reducer 同时 `repositories.select(worktreeID)`。 - -副作用:聚焦后 registry 监听 focus 事件、把对应 entry 的 `seen` 翻 true,UI 上 `done` 立刻降级成 `idle`。 - -### 2.6 UX 收尾 - -- min panel height: 120pt;max: container height − 200pt(保证 worktree list 可见) -- 拖动时 throttle 持久化(避免每帧写 UserDefaults) -- 动画 spring 参数:`response: 0.4, dampingFraction: 0.85`(手感舒服,不弹) -- Dynamic Type 友好:所有文字走 `.font(.caption)` / `.body` 等系统 style -- `.scrollIndicators(.never)` 与 worktree list 一致 - ---- - -## File Map - -### 新增 - -**Ghostty fork patches** (在 `onevcat/ghostty` 的 `release/v1.3.1-patched` 分支) - -- `src/Surface.zig` — `getChildPid()` -- `src/apprt/embedded.zig` — `ghostty_surface_pid` C export - -**Prowl 主仓库** - -- `supacode/Infrastructure/AgentDetection/ProcessDetection.swift` -- `supacode/Infrastructure/AgentDetection/AgentClassifier.swift` -- `supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift` (可拆 `Detectors/{Claude,Codex,Gemini,Cursor,Cline,OpenCode,Copilot,Kimi,Droid,Amp,Pi}Detector.swift`) -- `supacode/Domain/AgentDetection/AgentRawState.swift` -- `supacode/Domain/AgentDetection/DetectedAgent.swift` -- `supacode/Domain/AgentDetection/PaneAgentState.swift` -- `supacode/Features/ActiveAgents/Models/ActiveAgentsRegistry.swift` -- `supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift` -- `supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift` -- `supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift` -- `supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift` -- `supacodeTests/AgentDetection/...` — 多个测试文件,移植 herdr `detect.rs` 测试 fixture - -**文档** - -- `doc-onevcat/active-agents-panel.md` — 本计划 -- `doc-onevcat/change-list.md` — 增加 "Ghostty fork patches" 段 -- `doc-onevcat/fork-sync-and-release.md` — 增加 Ghostty fork rebase 子节 - -### 修改 - -- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — per-surface agent state,spawn detection task -- `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — `readViewportText()` / `childPID` -- `supacode/Clients/Terminal/TerminalClient.swift` — 新事件 + `focusSurface` 命令 -- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — 转发事件 + 实现 focusSurface -- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` — 嵌入 `ActiveAgentsFeature` -- `supacode/Features/Repositories/Views/SidebarListView.swift` — 嵌入 ZStack + ActiveAgentsPanel + transition -- `supacode/Features/Repositories/Views/SidebarFooterView.swift` — 加 hide toggle -- `supacode/Features/Terminal/BusinessLogic/TerminalTabManager.swift` — 扩展 focus 到 surface 级 -- `Frameworks/GhosttyKit.xcframework/.../ghostty.h` — 头文件同步(`make build-ghostty-xcframework` 自动) -- `.gitmodules` — `ThirdParty/ghostty` URL 改指 `onevcat/ghostty` - ---- - -## Verification - -**Phase 0(spike,最先做)**: - -1. 写小 smoke test 验证 `proc_pidinfo` / `proc_listallpids` / `sysctl(KERN_PROCARGS2)` 调用方式正确(详见 §1.7;Prowl 不在 sandbox 下,不预期阻碍) -2. 临时给 GhosttyKit 加 `ghostty_surface_pid`(在 submodule 里直接改,不进 PR),跑通 → 验证 fork 方案可行 -3. 决定方向后 setup `onevcat/ghostty` fork 正式落地(`release/v1.3.1-patched` 分支) - -**Phase 1 验证(不依赖 UI)**: - -1. `make test` — 重点跑 `AgentDetectionTests` / `ScreenHeuristicsTests`,所有从 herdr 移植的 fixture 必须通过 -2. `make run-app` Debug 构建,开 1 个 pane 跑 `claude`、1 个跑 `codex`、1 个跑 `bash`: - - `make log-stream` 观察 `agentStateChanged` 事件序列:claude 启动 → working → blocked (问 yes/no) → working → idle 全链路 - - 关掉 agent 后状态在 6 次 miss 内(约 1.8s @ 300ms tick)回到 unknown -3. Split 一个 pane 在同一 tab 里再跑一个 agent,确认 per-surface 粒度成立 - -**Phase 2 验证(UI)**: - -1. `make build-app` + `make run-app` — sidebar 底部出现 Active Agents 面板 -2. 点击 footer toggle:面板**从底部滑入/滑出**,动画顺畅;状态在重启 app 后保持 -3. 拖动 panel 顶边 drag handle:高度变化,重启后保持 -4. 列表实时反映 agent 状态: - - 启动 claude → 出现一条 entry,状态 working - - claude 问 `Do you want to proceed?` → entry 切到 blocked,红色徽章 - - 在另一个 worktree 等 claude 完成 → entry 切到 done,蓝色(未读) - - 点击 → sidebar 选中切到那个 worktree,tab 切对,pane 聚焦,done 立刻降为 idle -5. 关掉所有 agent,列表显示空态 -6. `make check` 通过;`make test` 全绿 - -**Manual smoke**: - -- 同一 tab 双 split:claude 在左、codex 在右,两条 entry 同时存在且独立 -- agent 通过 `node /path/to/codex` 间接启动也能识别(cmdline 扫描) -- 切换 worktree 时 `seen` 标记正确翻转 -- 长时间运行 (>10min) 不爆 CPU:detection tick 应非常便宜 - ---- - -## 风险与开放项 - -1. **Process syscall 调用形态**(低)— Prowl 无 sandbox,syscall 应直接可用;Phase 0 smoke test 验证一次即可,不预期阻碍。见 §1.7 -2. **Ghostty fork 维护成本** — 见顶部"关于 Ghostty fork"段。每次上游版本升级要新建 `release/v-patched` 分支并 cherry-pick patches,可接受 -3. **Agent 列表的扩展性** — 新 agent 需要同时改 enum + classifier + detector + 测试。可接受,与 herdr 同 -4. **Hook integration(未来)** — 本计划完全不做。Phase 1+2 落地稳定后,再考虑给 claude/codex/opencode 装 hook 上报权威状态(herdr 的 socket API 模型可以照搬) -5. **ScreenHeuristics 维护策略——为什么不嵌 herdr 二进制**: - - 考虑过把 herdr 的 Rust `detect.rs` 编成 dylib 直接链接。**最终选择走 Swift 移植路线**,理由: - - - **detect.rs 95% 是 `.contains("...")` 调用**,没有 Rust-only 算法精华,移植 1-2 天搞定 - - 嵌 Rust 二进制要加 cross-compile pipeline、universal dylib 签名、hardened runtime + 第三方 dylib 的 library validation 豁免、FFI marshaling(每 tick 跨 boundary 传 viewport text) - - herdr `detect.rs` 不是干净 leaf 模块——`use crate::platform::ForegroundJob` 跟其他模块耦合,要么编整个 crate 要么 fork 出 sub-crate - - 维护成本不会因为嵌入而消失:agent CLI 升级时 herdr 自己也得跟,我们等 herdr release 反而**延迟更长** - - 二次定制(Kimi 中文 pattern)会变成"改 Rust + 重编 + 重签 + 重 ship"——比改 Swift 痛苦 N 倍 - - **代替方案:drift-check skill**(一次性投入半天): - - - 新建 `~/.claude/skills/herdr-detect-sync/SKILL.md`,每月 onevcat 主动跑一次(或加到 cron) - - 拉最新 `https://raw.githubusercontent.com/ogulcancelik/herdr/master/src/detect.rs` - - 跟 `supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift` 做语义 diff:提取每个 agent detector 的 pattern 字符串列表,对比新增 / 删除 - - 输出"herdr 新加了哪些 pattern" + "我们有但 herdr 删了哪些"的报告,onevcat review 后手动 cherry-pick 进 Swift - - 这样我们在跟进 agent CLI 变化上**不慢于 herdr**(甚至能更快——不用等他们 release) -6. **panelHeight 在 sidebar 整体高度变化时的 clamp** — 窗口缩小到 worktree list 没空间时要自动让出。需要在 layout 里加 `GeometryReader` 或在 onChange 里 clamp。(CLAUDE.md "Avoid GeometryReader when containerRelativeFrame() ... would work"——优先尝试 containerRelativeFrame) diff --git a/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md b/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md deleted file mode 100644 index 964bf77e..00000000 --- a/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md +++ /dev/null @@ -1,285 +0,0 @@ -# Command Palette Architecture Refactor - -## Context - -The command palette (`supacode/Features/CommandPalette/`) ships ~24 commands today. We want to bring it to **60–80 commands** by surfacing view toggles (Canvas/Shelf/Sidebar/Active Agents), navigation actions (next/prev worktree, worktree history), terminal/tab/pane operations, and find-in-terminal — actions currently reachable only via hotkeys or menu items. - -Before adding commands, the existing architecture has three issues that would compound at scale: - -1. **Confusing visibility model.** Two booleans (`isGlobal`, `isRootAction`) collapse into a single bit of meaningful state. `isGlobal` is named as if it controls "appears in search", but every command participates in search regardless. `isRootAction` is a pure negative override — its only effect is to *hide* items from the empty-query suggestion list. The 8 app-level commands set both flags to `true`, so they never appear when the palette opens; opening Cmd+P shows a blank list in normal use. -2. **No keyword aliases.** The fuzzy scorer only matches `title` and `subtitle`. `Toggle Sidebar` cannot be found by typing `sb`. At 60+ commands users will rely heavily on short queries — keyword support is load-bearing for discoverability. -3. **High cost-per-command.** Adding one command requires changes in `CommandPaletteItem.Kind`, the builder in `CommandPaletteFeature.commandPaletteItems`, the delegate routing in `AppFeature`, and icon/badge rules in the overlay view. There's no factory to compress repetitive registration (e.g., commands that simply forward to an `AppShortcut`). - -This plan refactors the foundations first (PR1–PR3), then batches the actual command additions (PR4+). - ---- - -## Design Overview - -### New `CommandPaletteItem` shape - -```swift -struct CommandPaletteItem: Identifiable, Equatable { - let id: String - let title: String - let subtitle: String? - let kind: Kind - let priorityTier: Int - let category: Category // NEW: required, drives section grouping - let keywords: [String] // NEW: aliases that participate in fuzzy match - let defaultSuggestion: Bool // NEW: replaces isGlobal + isRootAction -} - -enum Category: String, CaseIterable { - case view // Toggle Sidebar / Canvas / Shelf / Active Agents / Diff - case navigation // Next/Prev worktree / tab / pane / shelf book / history - case worktree // New / Refresh / Archive / Remove / Run / Stop / Open - case pullRequest // Open / Merge / Close / Ready / CI actions - case terminal // Font size / Find / Ghostty-bridged commands - case app // Settings / Check Updates / Open Repository / Install CLI - #if DEBUG - case debug - #endif -} -``` - -### Why a single `defaultSuggestion` bit (not three-state) - -A three-state enum (`alwaysSuggest / onSearch / contextual`) overlaps with what the builder already does. The current builder is context-aware — it only constructs PR commands when an open PR exists; it only adds the worktree icon-change command when a worktree is selected; it filters out unwanted Ghostty actions. **Contextuality lives in command construction**, not in the visibility flag. - -That means `defaultSuggestion: Bool` is sufficient and uniform: an item is suggested when (a) it was constructed (the builder decided the context is right) **and** (b) the static `defaultSuggestion` flag is true. PR commands appear in Suggested when a PR exists because the builder constructs them then, not because of any PR-specific filter in the suggestion logic. - -This satisfies the constraint *"don't special-case PR commands"* — the suggestion view is one filter + one sort. - -### Empty-query rendering (post-PR2) - -``` -┌──────────────────────────────────────────┐ -│ [search box] │ -├──────────────────────────────────────────┤ -│ Recent │ -│ • Toggle Canvas ⌘⌥↩ │ -│ • New Worktree ⌘N │ -│ Suggested │ -│ • Toggle Sidebar ⌘⌃S │ -│ • Check for Updates ⌘⇧U │ -│ • Open Settings ⌘, │ -│ • … │ -└──────────────────────────────────────────┘ -``` - -- **Cap at 8 rows total** (5 Recent + 3 Suggested, dynamic fill). -- **Recent** = items with non-zero recency score, ordered by score desc. -- **Suggested** = remaining items with `defaultSuggestion == true`, ordered by `priorityTier` then declaration order, dedup'd against Recent. -- **Section headers only render on empty query.** When the user types, the scorer takes over: flat, fuzzy-ranked, no headers. - -### Keyword scoring - -`doScoreFuzzy` will score `title` and each entry in `keywords` independently and take the max. The matched label positions returned for highlighting always come from the `title` scoring run, even when a keyword scored higher — so the UI never paints highlights at indexes that don't exist in the visible string. Keywords are short labels (1–3 words), 0–5 per command, and not displayed. - -### Factory for `AppShortcut`-backed commands - -```swift -extension CommandPaletteItem { - static func appShortcut( - id: String, - title: String, - category: Category, - keywords: [String] = [], - defaultSuggestion: Bool = true, - priorityTier: Int = defaultPriorityTier, - kind: Kind - ) -> CommandPaletteItem { ... } -} -``` - -Most batch additions in PR4+ collapse to single-line calls like: - -```swift -.appShortcut( - id: "view.toggle-sidebar", - title: "Toggle Sidebar", - category: .view, - keywords: ["sb", "hide", "left panel"], - kind: .toggleSidebar -) -``` - ---- - -## PR1 — Model Refactor (no behavior change) - -**Goal:** swap the two-flag model for `category` + `keywords` + `defaultSuggestion` without changing what the user sees. This PR is pure refactor; any UI/UX change goes to PR2. - -### Scope - -1. **Add `Category` enum** in `CommandPaletteItem.swift`. Six base cases plus `#if DEBUG case debug`. -2. **Modify `CommandPaletteItem`**: - - Add `category: Category` (required init param) - - Add `keywords: [String]` (default `[]`) - - Add `defaultSuggestion: Bool` (required init param) - - Delete `isGlobal: Bool` computed property - - Delete `isRootAction: Bool` computed property -3. **Update `commandPaletteItems` builder** (`CommandPaletteFeature.swift:168-266`) to pass `category` and `defaultSuggestion` for each construction site. Mapping table below — **`defaultSuggestion` must equal `current isGlobal && !isRootAction`** so empty-query behavior is byte-identical. -4. **Update `filterItems`** (line 159–163): replace `items.filter(\.isGlobal).filter { !$0.isRootAction }` with `items.filter(\.defaultSuggestion)`. -5. **Update `ghosttyCommandItems`** helper (line 737–749) to pass `category: .terminal, defaultSuggestion: false`. -6. **Update tests** (`supacodeTests/CommandPaletteFeatureTests.swift`, `AppFeatureCommandPaletteTests.swift`): every `CommandPaletteItem(...)` construction needs the new fields. Existing assertions should still pass — that *is* the verification. - -### Command tagging table - -| Kind | Category | defaultSuggestion (= current `isGlobal && !isRootAction`) | -|---|---|---| -| `checkForUpdates` | `.app` | false | -| `openSettings` | `.app` | false | -| `openRepository` | `.app` | false | -| `installCLI` | `.app` | false | -| `newWorktree` | `.worktree` | false | -| `refreshWorktrees` | `.worktree` | false | -| `viewArchivedWorktrees` | `.worktree` | false | -| `jumpToLatestUnread` | `.navigation` | false | -| `worktreeSelect` | `.navigation` | false | -| `removeWorktree` | `.worktree` | false | -| `archiveWorktree` | `.worktree` | false | -| `changeFocusedTabIcon` | `.worktree` | false | -| `ghosttyCommand` | `.terminal` | false | -| `openPullRequest` | `.pullRequest` | **true** | -| `openRepositoryOnCodeHost` | `.pullRequest` | false | -| `markPullRequestReady` | `.pullRequest` | **true** | -| `mergePullRequest` | `.pullRequest` | **true** | -| `closePullRequest` | `.pullRequest` | **true** | -| `copyFailingJobURL` | `.pullRequest` | **true** | -| `copyCiFailureLogs` | `.pullRequest` | **true** | -| `rerunFailedJobs` | `.pullRequest` | **true** | -| `openFailingCheckDetails` | `.pullRequest` | **true** | -| `debugTestToast` | `.debug` | **true** | -| `debugSimulateUpdateFound` | `.debug` | **true** | - -Result: in normal usage, empty Cmd+P still shows the same things it did before (nothing in the no-PR case; PR commands when a PR is open). - -### Verification - -- Existing `filterItems` test suite passes unchanged (the public observable behavior is identical). -- Add one new test: `filterItems_emptyQuery_returnsOnlyDefaultSuggestionItems` asserting the post-refactor field reads correctly. -- `make build-app` succeeds. -- `make check` clean (formatting, swiftlint, swift-format). - -### Out of scope (deferred to PR2) - -- No change to which commands have `defaultSuggestion = true`. The 8 app-level commands stay hidden from empty palette in PR1. -- No keyword data populated yet (`keywords: []` everywhere). -- No section headers in the view. -- No scorer changes. - ---- - -## PR2 — Search & Empty-State UX - -**Goal:** make the palette useful on open, and let users search via short aliases. - -### Changes - -1. **Scorer**: extend `doScoreFuzzy` so each candidate scores against `[title] + keywords`, taking the max. Match highlight positions are always computed against `title`, never keywords. -2. **`filterItems` empty-query path**: replace the simple `defaultSuggestion` filter + sort with a Recent/Suggested split (see Design Overview rendering box). Cap at 8 total. -3. **`CommandPaletteOverlayView`**: add a tiny `Section` wrapper that renders headers — only when the active query is empty. When searching, headers disappear. -4. **Flip `defaultSuggestion` to `true` for the 8 app-level commands.** Add starter keywords: - - `checkForUpdates` — `["update", "version"]` - - `openSettings` — `["preferences", "config"]` - - `openRepository` — `["repo", "add repo"]` - - `newWorktree` — `["worktree", "branch"]` - - `refreshWorktrees` — `["reload", "rescan"]` - - `viewArchivedWorktrees` — `["archive", "history"]` - - `jumpToLatestUnread` — `["unread", "bell", "notification"]` - - `installCLI` — `["cli", "command line", "terminal", "prowl"]` - -### Verification - -- New tests for keyword matching (`Toggle Sidebar` findable via `sb`, etc.). -- New tests for Recent/Suggested split (8-cap, dedup, ordering by recency then priority). -- New tests asserting headers render only when query is empty. -- Manual smoke: open Cmd+P → see populated suggestions; type `sb` → see Toggle Sidebar (note: this command is added in PR4, so PR2's keyword tests use the 8 app-level commands' new keywords). - ---- - -## PR3 — Factories - -**Goal:** make PR4+ command additions one-liners. No behavior change. - -### Additions - -1. **`CommandPaletteItem.appShortcut(id:title:category:keywords:defaultSuggestion:priorityTier:kind:)`** factory — handles the most common case where a command forwards to a hotkey already registered in `AppShortcuts`. -2. **`CommandPaletteItem.ghosttyCommand(_:category:keywords:defaultSuggestion:)`** factory — consumes a `GhosttyCommand` value and returns a tagged item. Replaces `ghosttyCommandItems` inline construction. -3. **`CommandPaletteItem.contextual(id:title:category:kind:)`** factory — for items that are constructed only when context allows (worktree commands, PR commands). `defaultSuggestion` defaults to `false` here. - -### Verification - -- Refactor existing builders in `commandPaletteItems` to use the new factories. Tests must still pass. -- `make build-app` succeeds. - ---- - -## PR4+ — Batch Command Additions - -Each PR adds one category's worth of commands. Suggested order (high → low priority based on user feedback): - -### PR4: View toggles + Diff - -- Toggle Sidebar (`⌘⌃S`) -- Toggle Active Agents Panel (`⌘⌥P`) -- Toggle Canvas (`⌘⌥↩`) -- Toggle Shelf (`⌘⇧↩`) -- Show Diff (`⌘⇧Y`) - -All `category: .view`, `defaultSuggestion: true`. - -### PR5: Navigation - -- Select Next / Previous Worktree (`⌘⌃↑/↓`) -- Back / Forward Worktree History (`⌘⌥[` / `⌘⌥]`) -- Open Worktree in Finder (`⌘O`) -- Copy Worktree Path (`⌘⇧C`) -- Reveal in Sidebar (`⌘⇧L`) - -All `category: .navigation`. Suggested = high-traffic only (Next/Prev Worktree, Jump to Unread already exists). - -### PR6: Worktree actions - -- Run Script (`⌘R`) -- Stop Script (`⌘.`) -- Pin / Unpin Worktree -- Delete Worktree -- Rename Branch (`⌘⇧M`) - -### PR7: Terminal / Tab / Pane - -Most pipe through Ghostty's existing actions — confirm each action key is exposed via `GhosttyCommand`. If exposed, register via the existing `.ghosttyCommand` factory; otherwise we may need a Ghostty-side patch (defer that conversation). - -- Select Tab 1-9, Prev/Next Tab, Prev/Next Pane, Pane Up/Down/Left/Right -- Font size: increase / decrease / reset -- Find / Find Next / Find Previous / Hide Find -- New / Close Terminal / Close Tab - -### PR8: Shelf navigation - -- Select Next / Previous Shelf Book -- Select Shelf Book 1-9 - -### Stretch (no PR yet) - -- Repository context menu actions (Settings, Remove) -- Bulk selection actions (Archive Selected, Delete Selected) -- Confirm Worktree Action (`⌘↩`) - ---- - -## Non-goals - -- **No registry pattern.** The centralized builder stays — moving to per-feature command contribution is a larger architectural shift that doesn't justify itself at this scale. -- **No frequency tracking on top of recency.** The current exponential-decay recency model is good enough; adding a frequency counter is a measurable-impact-later question. -- **No declarative "availability" framework.** Context conditions stay as `if` branches in the builder. Pulling them out would force every command kind to define an availability predicate, which is heavy for the current ~24 → ~80 jump. -- **No virtualization.** SwiftUI `ForEach` in a `ScrollView` will handle 80 rows fine on macOS 26+. - -## Open questions (defer to PR2 design review) - -- Should `Recent` show a relative timestamp ("2m ago")? Probably not — adds visual noise for marginal value. -- When a command becomes contextually applicable mid-session (e.g., a PR opens), should its priority in Suggested temporarily boost? Current plan: no — it just shows up because the builder includes it. -- Should keywords be localized? Today the app is English-only; defer until we add localization. diff --git a/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md b/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md deleted file mode 100644 index 3fb728cd..00000000 --- a/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md +++ /dev/null @@ -1,170 +0,0 @@ -# Prowl CLI Agents Command Plan - -## Context - -Issue: - -The request is to expose the same Active Agents roster that Prowl already shows -in the sidebar through the `prowl` CLI. The CLI should be read-only for this -feature: switching/focusing is already covered by existing commands such as -`prowl focus --pane `, `prowl read --pane `, and `prowl send --pane `. - -Before implementing the command, agent detection scheduling should be made -reliable and efficient independently of the Active Agents panel visibility. The -CLI command should not depend on whether the panel is expanded, whether Shelf -status markers are visible, or any other UI-only preference. - -## Proposed Command - -Add: - -```bash -prowl agents -prowl agents --json -``` - -Do not add a first-class "switch agent" subcommand. Users and automation can -resolve `pane.id` from `prowl agents --json`, then call existing pane-oriented -commands. - -## Output Semantics - -`prowl agents` should expose detected agents, not the worktree-level task status -from `prowl list`. - -Important distinction: - -- `prowl list` currently reports `task.status` at worktree level as - `running | idle | null`. -- `prowl agents` should report per-pane agent detection state as - `working | blocked | done | idle`, plus the raw detector state. - -The command should return only panes where an agent is currently detected or has -a retained Active Agents entry. Empty shells and ordinary non-agent commands -should not appear. - -## JSON Schema Sketch - -Schema version: `prowl.cli.agents.v1` - -```json -{ - "count": 2, - "agents": [ - { - "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", - "type": "codex", - "name": "codex", - "status": "blocked", - "raw_state": "blocked", - "last_changed_at": "2026-06-13T04:12:25Z", - "project": { - "name": "Prowl", - "branch": "feature/cli-agents", - "path": "/Users/onevcat/Sync/github/Prowl" - }, - "worktree": { - "id": "Prowl:/Users/onevcat/Sync/github/Prowl", - "name": "feature/cli-agents", - "path": "/Users/onevcat/Sync/github/Prowl", - "root_path": "/Users/onevcat/Sync/github/Prowl", - "kind": "git" - }, - "tab": { - "id": "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", - "title": "issue 330", - "selected": true - }, - "pane": { - "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", - "index": 1, - "title": "codex", - "cwd": "/Users/onevcat/Sync/github/Prowl", - "focused": false - } - } - ] -} -``` - -Notes: - -- `id` should equal `pane.id` / `surfaceID`, matching Active Agents entries. -- `type` should be the normalized `DetectedAgent.rawValue`. -- `name` should be `ActiveAgentEntry.displayName`, preserving command aliases - such as `omp`. -- `status` should be `ActiveAgentEntry.displayState.rawValue`. -- `raw_state` should be `ActiveAgentEntry.rawState.rawValue`. -- `last_changed_at` should use ISO-8601. - -## Project vs Owning Worktree - -An agent may run in a different directory than the worktree that owns its -terminal pane. The CLI should expose both: - -- `project`: display-oriented repository/branch resolved from - `ActiveAgentEntry.workingDirectory`, using the same rules as the Active Agents - panel (`SidebarListView.activeAgentRowDisplay`). -- `worktree`: the actual terminal owner, used for focus/read/send targeting. - -This prevents automation from losing the concrete pane while still showing the -human-facing project label users expect. - -## Text Rendering - -Default text output should optimize for scanability: - -```text -Blocked codex Prowl:feature/cli-agents issue 330 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 -Working claude Notes:main review EF65FF31-1B72-40B2-80DA-3AA87B7B6858 -``` - -Suggested ordering: - -1. `blocked` -2. `working` -3. `done` -4. `idle` - -Within each status group, preserve Active Agents insertion order unless a later -UX pass finds a better sort. - -## Implementation Plan - -1. Add shared CLI input/payload models: - - `AgentsInput` - - `AgentsCommandPayload` - - `AgentsCommandAgent` - - nested `project`, `worktree`, `tab`, and `pane` payload structs -2. Add `Command.agents(AgentsInput)` and route it through `CLICommandRouter`. -3. Add `AgentsCommandHandler`. - - Snapshot source: `appStore.state.repositories.activeAgents.entries` - - Repository metadata: reuse `SidebarListView.activeAgentWorktreeMetadata` - and `SidebarListView.activeAgentRowDisplay`. - - Terminal metadata: reuse existing target/list snapshot builders where - possible to resolve tab selected state, pane title, cwd, and focus. -4. Add `ProwlCLI/Commands/AgentsCommand.swift` and register it in - `ProwlCommand`. -5. Add text rendering in `OutputRenderer.renderAgents`. -6. Update `docs/components/cli.md` and `docs/components/active-agents.md`. - -## Test Plan - -- Command envelope round-trip for `agents`. -- Router dispatch test. -- Handler payload test covering: - - status/raw state passthrough - - alias display name (`omp` vs `pi`) - - project label from `workingDirectory` - - owning worktree/pane still present - - focused pane marking -- CLI integration test for JSON output. -- CLI text rendering test for status ordering and empty state. - -## Open Questions - -- Whether `idle` agents should be included by default or hidden behind a flag. - Initial recommendation: include them because the Active Agents panel includes - retained idle/done entries, and automation can filter by status. -- Whether to add filtering flags such as `--status blocked` later. Initial - recommendation: skip flags for v1; JSON + `jq` is enough. diff --git a/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md b/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md deleted file mode 100644 index 7d178a7a..00000000 --- a/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md +++ /dev/null @@ -1,175 +0,0 @@ -# GhosttyKit Prebuilt Artifact Plan - -## Goal - -Make prebuilt GhosttyKit artifacts the default acquisition path for Prowl, while -keeping local Ghostty source builds available for fork maintenance and emergency -fallbacks. - -This is a long-term integration decision for Prowl: - -- Prowl pins `ThirdParty/ghostty` to the `onevcat/ghostty` fork. -- Each pinned Ghostty commit may have a matching GitHub Release artifact. -- Normal Prowl builds download and verify that artifact instead of compiling - Ghostty from Zig source. -- Ghostty source builds remain explicit maintenance operations. - -We are intentionally not moving GhosttyKit to a SwiftPM binary target for now. -Prowl's app target currently links `Frameworks/GhosttyKit.xcframework` directly -from the Xcode project and separately bundles `Resources/ghostty` and -`Resources/terminfo`. A Makefile downloader matches that shape with less churn. - -## Current State - -Prowl currently: - -1. Pins `ThirdParty/ghostty` as a submodule to `onevcat/ghostty`. -2. Runs `zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false` - from the Ghostty submodule. -3. Copies `ThirdParty/ghostty/macos/GhosttyKit.xcframework` to `Frameworks/`. -4. Copies Ghostty runtime resources from `zig-out/share/ghostty` and - `zig-out/share/terminfo` to `Resources/`. -5. Tracks `.ghostty_hash` and `.ghostty_build_stamp` locally to skip unchanged - rebuilds. - -The expensive part is step 2. Cold worktrees also frequently lack the generated -framework/resources, so `make build-app` currently triggers a full Ghostty build. - -## Artifact Model - -Publish artifacts from `onevcat/ghostty` GitHub Releases. - -Release tag format: - -```text -xcframework--prowl-v1 -``` - -Assets: - -```text -GhosttyKit.xcframework.tar.gz -GhosttyKit-resources.tar.gz -``` - -`GhosttyKit-resources.tar.gz` contains exactly: - -```text -ghostty/ -terminfo/ -``` - -The Prowl repository stores a reviewed checksum manifest: - -```text -scripts/ghosttykit-checksums.txt -``` - -Each non-comment line uses: - -```text - -``` - -The commit SHA is the gitlink recorded in Prowl, not "whatever the submodule -working tree currently reports". This allows cold worktrees to download -artifacts before the heavy Ghostty submodule is initialized. - -## Build Flow - -`make ensure-ghostty`: - -1. Read the pinned Ghostty gitlink with: - - ```bash - git rev-parse HEAD:ThirdParty/ghostty - ``` - -2. If `Frameworks/GhosttyKit.xcframework`, `Resources/ghostty`, and - `Resources/terminfo` already exist and `.ghostty_hash` matches, do nothing. -3. If a checksum entry exists, download both release assets for the pinned SHA. -4. Verify SHA256 for both assets. -5. Validate archive shape before extraction. -6. Extract into `Frameworks/` and `Resources/`. -7. Refresh `libghostty.a`'s archive index with `xcrun ranlib`. -8. Write `.ghostty_hash` and `.ghostty_build_stamp`. -9. If no pinned artifact exists or the download is unavailable, fall back to the - existing local Ghostty build. - -Checksum mismatch or unsafe archive shape is a hard failure. That indicates a -broken or suspicious artifact and should not silently fall back. - -`make sync-ghostty`: - -- Remains the explicit "force local rebuild from source" command. -- Requires the Ghostty submodule to be initialized. -- Continues to clear Xcode DerivedData after rebuilding. - -## Publishing Flow - -For a new Ghostty commit: - -1. Build from source on a machine with the required Xcode: - - ```bash - DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty - ``` - -2. Package artifacts: - - ```bash - scripts/package-ghosttykit-artifacts.sh - ``` - -3. Create the matching `onevcat/ghostty` release and upload both assets. -4. Add the emitted checksums to `scripts/ghosttykit-checksums.txt`. -5. Verify a clean acquisition path: - - ```bash - rm -rf Frameworks/GhosttyKit.xcframework Resources/ghostty Resources/terminfo .ghostty_hash .ghostty_build_stamp - make ensure-ghostty - make build-app - ``` - -## CI Flow - -The macOS setup action should: - -1. Use the pinned gitlink SHA for its cache key. -2. Restore the existing GitHub Actions cache when available. -3. Run `make ensure-ghostty` on cache miss. -4. Continue caching generated framework/resources and marker files. - -This keeps CI deterministic while making cache misses much faster. - -## Risks - -- **Artifact drift:** mitigated by pinned tag names and checked-in SHA256 values. -- **Unsafe archive extraction:** mitigated by validating tar entries and archive - roots before extraction. -- **Missing artifact for a new Ghostty commit:** local source build remains the - fallback, so development is not blocked. -- **Stale module caches after header changes:** `ensure-ghostty` clears - DerivedData when the pinned Ghostty SHA changes, preserving the current - behavior. - -## Non-Goals - -- No SwiftPM binary target migration in this phase. -- No dependency on upstream Ghostty release assets. -- No "latest release" behavior. -- No committed generated `GhosttyKit.xcframework` or runtime resources. - -## Implementation Checklist - -- Add artifact checksum manifest. -- Add archive validator. -- Add artifact packaging script. -- Add artifact ensure/download script. -- Wire `make ensure-ghostty` to the downloader with local build fallback. -- Update CI setup action to use the downloader. -- Update Ghostty fork sync documentation. -- Build/package/upload current `48365577c1ae8e422c0dd90489921f07b9f79171` - artifact. -- Verify `make ensure-ghostty` from missing local artifacts. -- Verify `make build-app`. diff --git a/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md b/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md deleted file mode 100644 index 6f6d3e15..00000000 --- a/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md +++ /dev/null @@ -1,228 +0,0 @@ -# Adaptive Line-Diff Strategy & Untracked File Badge - -Ref: [#488](https://github.com/onevcat/Prowl/issues/488) - -## Background - -PR #365 (2026-05-28) replaced fixed-cadence line-diff polling with an -event-driven model. The design was motivated by a user report (#364) of high CPU -usage in a very large repository — `git diff HEAD --shortstat` was running on all -worktrees at a fixed cadence regardless of whether anything had changed. - -The solution introduced: - -| Parameter | Value | Purpose | -|---|---|---| -| `filesChangedDebounceInterval` | 5 s | Debounce after HEAD watcher fires (branch switch / commit) | -| `lineChangesEventDebounceInterval` | 30 s | Debounce after FSEvents fires (file edits in active worktrees) | -| `lineChangesSafetyRefreshInterval` | 300 s | Fallback for missed FSEvents / sleep-wake | -| `isLineChangesActive` gate | selected ∪ opened | Only active worktrees start FSEvents + safety refresh | -| `observeLineDiffsAutomatically` | per-repo toggle | Escape hatch to disable line-diff entirely | - -These values were chosen conservatively for worst-case large repos. - -## Problem - -For normal-sized repos the 30 s FSEvents debounce makes the sidebar badge feel -"stuck". Users edit a file and the badge takes 30+ seconds to update (if they -keep editing, the timer keeps resetting). - -Issue #488 correctly identifies this lag but overstates how cheap -`git diff HEAD --shortstat` is. Our benchmarks on this machine: - -| Repo size | Dirty files | Wall time | -|---|---|---| -| 5 000 tracked files | clean | ~14 ms | -| 5 000 tracked files | 5 000 dirty | ~550 ms | -| 20 000 tracked files | clean | ~31 ms | -| 20 000 tracked files | 5 000 dirty | ~484 ms | -| 20 000 tracked files | 20 000 dirty | ~2.0 s | -| 50 000 tracked files | clean | ~67 ms | -| 50 000 tracked files | 10 000 dirty | ~1.1 s | - -`--shortstat` still computes line-level diffs (not just metadata) because it -reports `+N/-M` line counts. Cost scales with the number of dirty files, not -repo size alone. For large repos with agents modifying many files concurrently, -sub-second git processes at aggressive intervals compound into sustained CPU -load. - -A one-size-fits-all debounce interval cannot serve both audiences. - -### Additional finding: untracked files ignored by badge - -`GitClient.lineChanges()` runs `git diff HEAD --shortstat`, which only reports -tracked file changes. Newly created (untracked) files are invisible to the badge -while the Diff window (`⌘⇧Y`) includes them via `git ls-files --others ---exclude-standard`. This creates a user-visible inconsistency: the badge shows -+0/-0 but the Diff window lists new files. - -## Plan - -### Part 1: Adaptive debounce based on repo size - -**Core idea**: read the repository's tracked file count once (cheap), classify -the repo into a size tier, and use that tier to select debounce intervals. - -#### Reading the file count - -The git index binary format stores the entry count as a big-endian `UInt32` at -byte offset 8. Reading 12 bytes from the index file gives an exact count with -zero subprocess overhead. - -``` -bytes 0–3: signature ("DIRC") -bytes 4–7: version (2/3/4) -bytes 8–11: entry count (big-endian UInt32) -``` - -For worktrees the index lives at the worktree's own git directory (resolved via -the `.git` file → `gitdir:` pointer). Since all worktrees of the same repository -track roughly the same set of files, the count can be cached per -**repository root** and refreshed lazily (e.g. on `setWorktrees` or once per -app-foreground cycle). - -Implementation: add a method on `GitClient`: - -```swift -nonisolated func indexEntryCount(at gitDir: URL) -> Int? { - let indexURL = gitDir.appending(path: "index") - guard let handle = try? FileHandle(forReadingFrom: indexURL) else { return nil } - defer { try? handle.close() } - guard let header = try? handle.read(upToCount: 12), header.count == 12 else { return nil } - return Int( - header[8...11].withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } - ) -} -``` - -#### Size tiers and intervals - -| Tier | Tracked files | FSEvents debounce | HEAD debounce | Safety refresh | -|---|---|---|---|---| -| Small | < 5 000 | 2 s | 1 s | 300 s | -| Medium | 5 000 – 20 000 | 5 s | 2 s | 300 s | -| Large | > 20 000 | 15 s | 5 s | 300 s | - -The existing `observeLineDiffsAutomatically = false` toggle remains the hard -opt-out for truly massive repos where even 15 s is too aggressive. - -Thresholds are tentative — we can tune after real-world feedback. - -#### Where to apply - -`WorktreeInfoWatcherManager` currently takes `filesChangedDebounceInterval` and -`lineChangesEventDebounceInterval` as constructor parameters (single values for -all worktrees). Change these to be resolved **per worktree** by looking up the -cached repo file count and mapping to a tier. - -Specifically: -- `scheduleFilesChanged(worktreeID:)` — use the per-repo HEAD debounce. -- `scheduleLineChangesDebouncedRefresh(worktreeID:)` — use the per-repo FSEvents - debounce. - -The file count cache lives on `WorktreeInfoWatcherManager` as a -`[URL: Int]` dictionary keyed by repository root URL. It is populated when -worktrees are set / updated, and refreshed on app-foreground. No async work -needed — the index read is synchronous and takes <1 ms. - -#### Tier resolution - -Add a helper that maps a file count to debounce intervals: - -```swift -struct LineChangesTimingTier { - let filesChangedDebounce: Duration - let eventDebounce: Duration -} - -func lineChangesTimingTier(forFileCount count: Int) -> LineChangesTimingTier { - switch count { - case ..<5_000: - return LineChangesTimingTier(filesChangedDebounce: .seconds(1), eventDebounce: .seconds(2)) - case ..<20_000: - return LineChangesTimingTier(filesChangedDebounce: .seconds(2), eventDebounce: .seconds(5)) - default: - return LineChangesTimingTier(filesChangedDebounce: .seconds(5), eventDebounce: .seconds(15)) - } -} -``` - -### Part 2: Include untracked file lines in the badge - -#### Approach - -Count the **lines** in untracked files and fold them into the existing `+N` -number. No layout change to the badge — untracked lines are conceptually -"added lines" (they would show as `+` in a full diff). - -#### GitClient change - -`lineChanges(at:)` currently returns `(added: Int, removed: Int)?`. Keep the -same return type — the `added` count now includes untracked line counts. - -Inside `lineChanges(at:)`, run the existing `git diff HEAD --shortstat` and -`git ls-files --others --exclude-standard` concurrently via `async let`: - -```swift -async let diffOutput = runGit(operation: .lineChanges, arguments: [..., "diff", "HEAD", "--shortstat"]) -async let untrackedOutput = runGit(operation: .untrackedFilePaths, arguments: [..., "ls-files", "--others", "--exclude-standard"]) - -let tracked = parseShortstat(await diffOutput) -let untrackedPaths = parseUntrackedPaths(await untrackedOutput) -let untrackedLines = countLinesInFiles(untrackedPaths, relativeTo: worktreeURL) - -return (added: tracked.added + untrackedLines, removed: tracked.removed) -``` - -`countLinesInFiles` reads each file's `Data` and counts `0x0A` bytes. If a NUL -byte (`0x00`) appears in the first 8 KB, the file is treated as binary and -skipped (matches git's heuristic). This is pure in-process I/O with no -subprocess overhead. - -Performance: 1 000 untracked files × 100 lines = ~43 ms total (including the -`git ls-files` subprocess). The `async let` parallelism means it overlaps with -`git diff HEAD --shortstat` and adds minimal wall-clock time. - -#### Badge display - -No change to layout. The `+N` number now reflects tracked additions + -untracked file lines combined. - -Before: `+120 -45` (tracked only; creating a new 30-line file shows nothing) -After: `+150 -45` (30-line new file adds to the count) - -## Scope and non-goals - -- **Not changing `isLineChangesActive` gating**: inactive worktrees still don't - run FSEvents monitors. This is correct — a worktree you haven't opened doesn't - need sub-second freshness. The existing deferred refresh on open/select is - sufficient. -- **Not changing PR polling**: already batched via `PullRequestRefreshCoordinator` - into a single GraphQL call per host. Not a scaling concern. -- **Not exposing debounce intervals to users**: the adaptive tier handles it - automatically. `observeLineDiffsAutomatically = false` remains the manual - escape hatch. -- **Not replacing `git diff HEAD --shortstat`** with `git status --porcelain` or - similar. The current command gives exact line counts which the badge needs; - `--porcelain` would give file counts only. - -## Affected files - -| File | Change | -|---|---| -| `GitClient.swift` | Add `indexEntryCount(at:)`; add `countLinesInFiles` helper; extend `lineChanges()` to include untracked lines in `added` | -| `WorktreeInfoWatcherManager.swift` | Per-repo file count cache; per-worktree tier resolution for debounce intervals | -| `RepositoriesFeature+CoreReducer.swift` | No change needed — `added` already flows through | -| `WorktreeInfoWatcherManagerTests.swift` | Test tier selection; test debounce varies by repo size | - -## Testing - -- Unit test `indexEntryCount` with a hand-crafted 12-byte header. -- Unit test `lineChangesTimingTier` for boundary values. -- Unit test `countLinesInFiles`: text files counted, binary files (NUL in first - 8 KB) skipped, missing files skipped. -- Watcher manager tests: verify that worktrees in repos of different sizes get - different debounce intervals. -- Manual: open a small repo, edit a file, verify badge updates within ~2 s. - Create a new file, verify its lines appear in the `+N` count. - Toggle `observeLineDiffsAutomatically = false`, verify badge stops updating. diff --git a/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md b/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md deleted file mode 100644 index 6ca83ac0..00000000 --- a/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md +++ /dev/null @@ -1,180 +0,0 @@ -# Canvas Tile Layout(平铺占满视口) - -## Context - -Prowl 的画布模式(Canvas)当前提供两种卡片排序,入口在 `CanvasView.canvasToolbar` -(`supacode/Features/Canvas/Views/CanvasView.swift`)与命令面板: - -| 模式 | 快捷键 | 卡片尺寸 | 算法 | 入口函数 | -|------|--------|---------|------|---------| -| **Organize** | ⌘⌥G | 统一默认尺寸(`adaptiveDefaultCardSize`),**不随卡片当前大小变化** | √N 平衡网格(`gridColumns`/`gridPosition`) | `organizeCards()` | -| **Arrange** | ⌘⌥R | **保留每张卡片当前尺寸** | MaxRects 风格 bin-packing(`CanvasCardPacker`,waterfall vs row-break 竞争) | `arrangeCards()` | - -两者都把卡片放进**无限画布坐标系**,再由 `fitToView(canvasSize:)` 计算缩放/平移把整组卡片 -塞进视口(缩放上限 1.0,四周留 30pt padding,底部预留 `bottomToolbarReserve = 50`)。 - -卡片数据是 `CanvasCardLayout { position(center), size }`,存活在 `CanvasLayoutStore` -(`@Observable`,落 UserDefaults `canvasCardLayouts`),`zOrder` 决定渲染层级。 - -排序的触发是**三通路**复用同一套基建: - -1. 工具栏按钮 → `arrangeCardsWithFit()` / `organizeCardsWithFit()` -2. 键盘快捷键 → `body` 的 `.onKeyPress` -3. 命令面板 → `AppFeature+CommandPalette` 发 `.repositories(.requestCanvasCommand(.arrange/.organize))` - → `CanvasCommandRequest.Command` → `CanvasView+Focus.fulfillCommandRequest()` - -## Goal - -新增**第三种**布局 **Tile**(⌘⌥T,图标 `rectangle.split.2x1`),定位为 -**自动平铺窗口管理器**式排序:把所有打开的卡片**重新调整尺寸**,按规整网格铺满整个可视 -画布,让用户用尽可能大的面积组织卡片。 - -与现有两种的本质区别:Organize 用固定默认尺寸、Arrange 保留卡片原尺寸,而 **Tile 由视口 -反推卡片尺寸**——这是它"占满"的关键。 - -### 行为规格(与 onevcat 对齐确认) - -**平衡网格 + 宽高比自适应**: - -- 短边(视觉上较短的轴)放 `s = max(1, floor(√N))` 条"线",N 张卡片在这 `s` 条线上 - 尽量均分,多出来的卡片放到**靠后的线**(靠下的行 / 靠右的列)。 -- **宽窗口(W ≥ H)→ 线即"行",左右铺开**;**高窗口(W < H)→ 线即"列",上下堆叠**。 - 这是纯粹的横/纵方向翻转(短边永远放 `floor(√N)` 条线)。 -- 每条线**独立铺满整条**:2 卡的行每张占 ½ 宽,3 卡的行每张占 ⅓ 宽(所以不同线上的 - 卡片尺寸可以不同——这才是"尽可能大")。同方向的所有线等分另一轴。 - -**线分配 `lineCounts(for: N)`**:`base = N / s`,`rem = N % s`;前 `s - rem` 条线各 -`base` 张,后 `rem` 条线各 `base + 1` 张。 - -| N | s = floor(√N) | 分配 | 宽窗口(行) | 高窗口(列) | -|---|---|---|---|---| -| 1 | 1 | [1] | 整屏 1 张 | 整屏 1 张 | -| 2 | 1 | [2] | 左右各半 | 上下各半 | -| 3 | 1 | [3] | 横排 3 | 竖排 3 | -| 4 | 2 | [2,2] | 2×2 | 2×2 | -| 5 | 2 | [2,3] | 上 2 下 3 | 左 2 右 3 | -| 6 | 2 | [3,3] | 2 行 ×3 | 2 列 ×3 | -| 7 | 2 | [3,4] | 上 3 下 4 | 左 3 右 4 | -| 8 | 2 | [4,4] | 2 行 ×4 | 2 列 ×4 | -| 9 | 3 | [3,3,3] | 3×3 | 3×3 | -| 10 | 3 | [3,3,4] | 3 行(3,3,4) | 3 列(3,3,4) | - -> 取舍说明:方向自适应是**二元翻转**(看 `W ≥ H`),不做极端宽高比的列数微调(例如 -> 32:9 超宽屏 4 张仍是 2×2,而非 1×4)。这保持了与上面确定性例子完全一致的"平衡网格" -> 观感。若日后想要极端比例下进一步铺开,可在 `lineCounts` 上叠加一层 aspect-aware 的 -> 候选评分(按最大化最小卡片面积选 `s`),属于后续增强、不在本次范围。 - -### 缩放策略(已确认 + 自适应增强) - -**复用现有 `fitToView`**:Tile 在画布坐标系按视口比例摆好卡片后,调用 `fitToView` 居中并 -缩放。因为布局 bounding box 的宽高比 ≈ 视口宽高比,`fitToView` 的 `min(W/bboxW, H/bboxH)` -会让两个方向同时贴合。 - -**自适应 zoom(v2 增强,回应"字太大、间距偏大"反馈)**:固定 scale=1 时,卡片多→单卡 -surface 小→终端行列少→字相对显得大、内容少。改进做法:`layout` 在一个 -`viewport × zoom` 的放大画框里铺卡,`fitToView` 自然得到 `scale ≈ 1/zoom`。 - -- `zoom = max(1, comfortableSize / 单卡 surface)`:卡片本就够大时 `zoom=1`(scale≈1, - 与单窗口体验一致);卡片缩小到 `comfortableSize` 以下时 `zoom>1`,surface 维持舒适 - 尺寸(更多行列、字更小、内容更多)。`comfortableSize = adaptiveDefaultCardSize × 0.6`, - 让少量卡片保持原生 scale,再平滑过渡。 -- **间距**:Tile 用更小的 `tileCardSpacing = 14`(其余模式 20);它活在放大画框里,屏幕 - 间距 = `14 × scale`,会随卡片增多自动收紧——同时解决"间距偏大"与"不随尺寸适配"。 -- `fitToView` 的 scale 夹在 `[0.25, 1.0]`:`zoom>1 → scale≤1`;极端卡片数 zoom 很大时 - scale 触底 0.25、卡片轻微溢出,属可接受降级。 - -## 算法细节 - -新增可单测的纯逻辑类型 `CanvasTileLayout`,与 `CanvasCardPacker` 并列放在 -`CanvasCardLayout.swift`: - -``` -struct CanvasTileLayout { - var spacing: CGFloat - var titleBarHeight: CGFloat - // clamp 边界(minCard*/maxCard*)由调用方传入或用默认 - - static func lineCounts(for count: Int) -> [Int] // 上面的分配规则 - func layout(keys: [String], viewport: CGSize) -> [String: CanvasCardLayout] -} -``` - -`layout` 几何(以**宽窗口=行**为例,高窗口为对称转置): - -- `rows = lineCounts(for: keys.count)`,`rowVisualHeight = (H - (rows+1)·spacing) / rows`, - `terminalHeight = rowVisualHeight - titleBarHeight`。 -- 第 `r` 行有 `k` 张:`cardWidth = (W - (k+1)·spacing) / k`。 -- 卡片中心:`y = spacing + r·(rowVisualHeight + spacing) + rowVisualHeight/2`; - `x = spacing + i·(cardWidth + spacing) + cardWidth/2`。 -- `CanvasCardLayout(position: center, size: CGSize(cardWidth, terminalHeight))`。 - **不做 min/max 夹紧**:tile 的卡片尺寸就是视口除以网格的结果,夹紧只会在窗口过小时 - 把卡片撑大到超出格子、造成重叠。min/maxCard 约束属于"手动拖拽 resize"与"新卡默认 - 尺寸"的范畴,与 tile 的"按视口铺满"无关。窗口很小时卡片会变小(低于默认尺寸),由 - `fitToView` 负责后续视觉缩放——与 Organize 的降级思路一致,但保证恒不重叠、恰好铺满。 - -高窗口对称:线=列,`colVisualWidth = (W-(cols+1)·spacing)/cols`,每列 `k` 张时 -`cardVisualHeight = (H-(k+1)·spacing)/k`、`terminalHeight = cardVisualHeight - titleBarHeight`。 - -边界:`count == 0` 或 `viewport` 任一维 ≤ 0 时返回空 dict(调用方 no-op,与 `arrangeCards` -的 guard 一致)。 - -## 改动清单(按文件) - -### 1. 核心算法 — `supacode/Features/Canvas/Models/CanvasCardLayout.swift` -新增 `CanvasTileLayout`(`lineCounts(for:)` + `layout(keys:viewport:)`)。纯函数、无副作用、 -`@MainActor` 无关,便于单测。 - -### 2. 触发逻辑 — `supacode/Features/Canvas/Views/CanvasView.swift` -- `func tileCards()`:取 `collectCardKeys` → `CanvasTileLayout(...).layout(keys:viewport:)` - → `layoutStore.setCardLayouts(result, zOrder: keys)`(仿 `organizeCards()`)。guard 视口有效。 -- `func tileCardsWithFit()`:`withAnimation(.easeInOut(0.2))` 内 `cancelExpandForRelayout()` - + `tileCards()` + `fitToView(canvasSize: viewportSize)`(仿 `*WithFit`)。 -- `body` 顶部新增 `tileCanvasShortcut = AppShortcuts.resolvedShortcut(for: .tileCanvasCards, ...)`。 -- 新增一条 `.onKeyPress(tileCanvasShortcut?.keyEquivalent ?? AppShortcuts.tileCanvasCards.keyEquivalent, phases: .down)`, - 模式与 arrange/organize 完全一致(解析为 nil 时 `.ignored`,校验 modifiers)。 -- `canvasToolbar` 第三个按钮:`Image(systemName: "rectangle.split.2x1")`, - `help(AppShortcuts.helpText(title: "Tile cards to fill the canvas", commandID: .tileCanvasCards, ...))`。 - -### 3. 命令通路(接入 arrange/organize 的全套基建) -- `CanvasFocusRequest.swift`:`CanvasCommandRequest.Command` 加 `case tile`。 -- `CanvasView+Focus.swift`:`fulfillCommandRequest` 的 switch 加 `case .tile: tileCardsWithFit()`。 -- `AppShortcuts.swift`: - - `CommandID.tileCanvasCards = "tile_canvas_cards"`(≈ line 143 区) - - `static let tileCanvasCards = AppShortcut(key: "t", modifiers: [.command, .option])`(≈ line 303) - —— ⌘⌥T 当前空闲(已核对 ⌘⌥ 已用:p/u/return/[/]/方向键/a/r/g/e) - - 注册进命令表(≈ line 778-787 区,title `"Tile Canvas Cards"`) -- `AppFeature+CommandPalette.swift`:`case .tileCanvasCards: return .send(.repositories(.requestCanvasCommand(.tile)))`。 -- 命令面板枚举/映射四处:`CommandPaletteItem.swift`、`CommandPaletteFeature.swift` - (`kind` + 注册项 ≈ line 613-620 区)、`CommandPaletteSupport.swift` - (`globalTileCanvasCards` 常量 + 各 mapping ≈ line 19/192/267/319)、 - `CommandPaletteOverlayView.swift`(各 switch/list ≈ line 525/590/640/775)。 -- `ShortcutsSettingsView.swift`:canvas 快捷键列表加 `.tileCanvasCards`(≈ line 942)。 - -### 4. 测试 — 新建 `supacodeTests/CanvasTileLayoutTests.swift` -- `lineCounts(for:)`:断言 N=1…10 全部命中上表(重点覆盖 5→[2,3]、7→[3,4]、9→[3,3,3])。 -- `layout`: - - **宽窗口**(如 1600×900)N=2 → 两张左右、各约半宽、等高、无重叠。 - - **高窗口**(如 900×1600)N=2 → 两张上下(方向翻转生效)。 - - N=5 宽窗口 → 上排 2 下排 3,下排卡片更窄。 - - 通用:任意两卡矩形不相交;每行/列铺满对应轴;clamp 在极小视口下生效。 -- 复用 `CanvasCardPackerTests` 的无重叠/间距断言风格。 - -### 5. 文档(同 PR) -- `docs/components/canvas.md`:≈ line 57-59 追加 `⌘⌥T Tile Cards` 段落;line 6 keywords 加 `tile`。 -- `docs/reference/keyboard-shortcuts.md`:≈ line 67-68 加一行 - `| Tile Canvas Cards (fill viewport) | ⌘⌥T | \`tile_canvas_cards\` | yes (local) |`, - 必要时更新 line 132 的 local-action 说明。 - -### 6. 收尾 -- 新分支 `feature/canvas-tile-layout`(从最新 `origin/main`)。 -- `make build-app`、`make test`(含新测试)、`make check` 全绿。 -- 仅提交本次改动文件(不 `git add .`),开 PR 到 `onevcat/Prowl`。 - -## 验收标准 - -1. 画布有 2/3/4/5 张卡片时点 Tile,宽窗口下分别得到 左右 / 横排3 / 2×2 / 上2下3。 -2. 把窗口拉成竖屏后点 Tile,2 张变上下、3 张变竖排、5 张变左2右3。 -3. 卡片铺满可视区域(仅四周少量边距),无重叠、间距一致。 -4. 三通路(按钮 / ⌘⌥T / 命令面板 "Tile Canvas Cards")行为一致。 -5. 设置里能看到并改键,禁用后 ⌘⌥T 不触发。 -6. `lineCounts` 单测与布局单测通过。 diff --git a/doc-onevcat/plans/2026-06-27-foundation-model-branch-name.md b/doc-onevcat/plans/2026-06-27-foundation-model-branch-name.md deleted file mode 100644 index 946b3831..00000000 --- a/doc-onevcat/plans/2026-06-27-foundation-model-branch-name.md +++ /dev/null @@ -1,290 +0,0 @@ -# Foundation Model Auto Branch Name Suggestion - -## Context - -When creating a new worktree in Prowl, users must manually type a branch name (e.g., -`feature/my-change`). This is friction-heavy. We want to use Apple's on-device Foundation -Model (macOS 26+ `FoundationModels` framework) to automatically suggest a branch name -based on available context: terminal tab content/titles, existing branch naming conventions, -and repository name. - -If Foundation Model is unavailable (older hardware, etc.) or the suggestion fails, fall back -to the existing `WorktreeNameGenerator` (adjective-animal-NNN format, e.g., `bold-cat-042`). - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Timing | Dialog opens immediately, name fills async | Don't block UI; user can start typing | -| Fallback | Random name (adjective-animal-NNN) | Both prompt and non-prompt paths | -| Clipboard | Skip entirely | Avoid macOS paste indicator + privacy concerns | -| LLM layer | Protocol-based abstraction | Foundation Model as default; extensible for future backends | - -## Information Sources (by signal priority) - -1. **Terminal pane titles** — Set via OSC-2, often contain running commands. Available from - `bridge.state.title`. Cheap to read. -2. **Terminal active content** — Read via `readActiveContentsForCLI()` (active command area, - not full scrollback). Cap per worktree at ~300 chars, total ~1000 chars, max 3 worktrees. -3. **Existing branch names** — Already loaded in `baseRefOptions`. Use first 10 for naming - convention inference. -4. **Repository name** — Already in state. - -## Data Sources (by importance) - -| Priority | Source | Role in prompt | Notes | -|----------|--------|----------------|-------| -| 1 | Existing branch names (`baseRefOptions`) | Naming convention examples — model must match style | `feature/xxx`, `fix/xxx`, pure kebab, etc. | -| 2 | Same-repo worktree branch names (`Worktree.name`) | Current work context — what user is working on | Sorted by `lastDefocusedAt`, top 3 | -| 3 | Terminal pane titles (OSC-2) | Current work context — running commands/agent names | Grouped with branch names per worktree | -| 4 | Terminal active content | Supplementary context — errors, discussions | Truncated, appended last; noisiest source | -| 5 | Repository name | Background context | One line at prompt start | - -## Phase 0: Spike — Validate Foundation Model Capability - -Before committing to the full integration, build a standalone Swift command-line tool to -test Foundation Model's ability to generate useful branch names. - -**Goal**: Determine if on-device model quality and speed are sufficient. If not, skip AI -integration and only ship the random name feature. - -**Spike program** (temporary directory, not kept in repo): -- Simple Swift Package with `FoundationModels` import -- Hardcoded test scenarios simulating real context combinations -- Test cases: - 1. **Convention only** — give 10 branch names → can model infer and match the style? - 2. **Convention + worktree branches + pane titles** — give branch names + "current worktree: - feature/add-canvas-tile, pane title: claude" → does it produce a sensible related name? - 3. **Convention + terminal active content** — give branch names + truncated terminal output - (e.g., error messages, `git log` output) → can model extract intent from noisy text? - 4. **Full context** — all sources combined → best quality achievable? - 5. **Speed** — measure response latency per call. Is 3-second timeout realistic? -- Try 2-3 prompt variations per test case (directive style, few-shot examples, etc.) -- Log raw model output + sanitized branch name for each - -**Success criteria**: -- Model generates contextually relevant names in ≥ 3/5 test scenarios -- Response latency < 3 seconds on Apple Silicon -- Output is parseable (single line, no extra explanation) - -**If spike fails**: Ship only the random name (adjective-animal-NNN) feature, skip LLM layer. - -## Architecture - -### LLM Service Layer - -A lightweight protocol-based abstraction to decouple the LLM backend from business logic. -Foundation Model is the initial and default backend; the design preserves extensibility for -future backends (stronger models, remote LLMs, etc.) without over-engineering. - -``` -┌─────────────────────────────────────────────────┐ -│ BranchNameSuggestionClient (TCA Dependency) │ -│ - gatherContext(...) → context │ -│ - suggest(context) → String? │ -└──────────────┬──────────────────────────────────┘ - │ uses -┌──────────────▼──────────────────────────────────┐ -│ LLMService (protocol) │ -│ - func generate(prompt: String) async → String?│ -└──────────────┬──────────────────────────────────┘ - │ conforms -┌──────────────▼──────────────────────────────────┐ -│ FoundationModelLLMService │ -│ - Wraps LanguageModelSession (macOS 26+) │ -│ - Checks availability, handles timeout │ -└─────────────────────────────────────────────────┘ -``` - -**`LLMService` protocol** (`supacode/Infrastructure/LLM/LLMService.swift`): -```swift -protocol LLMService: Sendable { - var isAvailable: Bool { get async } - func generate(prompt: String) async throws -> String -} -``` - -**`FoundationModelLLMService`** (`supacode/Infrastructure/LLM/FoundationModelLLMService.swift`): -- Wraps `FoundationModels.LanguageModelSession` -- Checks `SystemLanguageModel.default` availability -- Applies 3-second timeout -- Returns raw text response - -**`BranchNameSuggestionClient`** (`supacode/Clients/BranchNameSuggestion/BranchNameSuggestionClient.swift`): -- TCA dependency consuming `LLMService` -- Gathers terminal context on `@MainActor` -- Builds prompt, calls `LLMService.generate`, sanitizes output -- Applies prefix enforcement (detect convention from existing branches, fallback `worktree/`) -- Validates result before returning (see validation rules below) -- Falls back to `nil` on any failure (caller handles random fallback) - -### Context Gathering - -```swift -struct BranchNameSuggestionContext: Sendable, Equatable { - let repositoryName: String - let existingBranchNames: [String] // first 10, for convention inference - let terminalContexts: [TerminalHint] - - struct TerminalHint: Sendable, Equatable { - let worktreeBranch: String // Worktree.name (= branch name) - let title: String // pane title (OSC-2) - let activeContent: String? // truncated active area text - } -} -``` - -`gatherContext` is a `@MainActor` closure wired in `supacodeApp.swift`, capturing -`terminalManager`. It: - -1. **Filters by same repo** — only includes worktrees where - `state.repositoryRootURL == targetRepositoryRootURL` -2. **Sorts by last active** — uses `WorktreeTerminalState.lastDefocusedAt` (new field, - set when worktree loses focus via `setSelectedWorktreeID`). Currently selected worktree - ranks first, then by `lastDefocusedAt` descending -3. **Takes top 3** — reads tab/pane titles via `makeCLIListSnapshot()` and active content - via `readActiveContentsForCLI()` for each worktree's focused pane -4. **Includes branch name** — `Worktree.name` per worktree, valuable context for the model -5. **Truncates** — per-pane active content capped at ~300 chars, total budget ~1000 chars - -### `lastDefocusedAt` Tracking - -Add `var lastDefocusedAt: Date?` to `WorktreeTerminalState`. Set it in -`WorktreeTerminalManager.handleCommand(.setSelectedWorktreeID)` on the **previous** state -(line 169 of `WorktreeTerminalManager.swift`) when focus moves away. Lightweight, in-memory -only, no persistence needed. - -### Prompt Design (V3, prefix-enforced) - -Spike validated that V3 (explicit prefix enforcement) performs best. The prompt dynamically -detects prefixes from existing branches and instructs the model to use them. - -``` -Suggest a single git branch name for a new branch in the "{repositoryName}" repository. - -Rules: -- Output ONLY the branch name, nothing else -- Maximum 50 characters -- IMPORTANT: Existing branches use prefixes: {detected prefixes}. You MUST use one of these prefixes. -- Do NOT repeat an existing branch name - -Existing branches: {first 10 branch names, comma-separated} -{if terminalContexts} -Current work in progress: -- Branch: {branch}, terminal: {paneTitle} | {activeContent truncated} -{/if} -``` - -When no prefix convention is detected (fresh repo), the prefix instruction is replaced with -"Use a descriptive kebab-case name." - -### Branch Name Sanitizer & Validation - -**`BranchNameSanitizer`** (`supacode/Domain/BranchNameSanitizer.swift`): - -**Sanitization** — convert arbitrary text to a valid git branch name: -- Trim whitespace, lowercase -- Replace spaces/underscores with hyphens -- Strip invalid git-ref characters (`~`, `^`, `:`, `\`, `?`, `*`, `[`, `..`, `@{`) -- Collapse consecutive hyphens, strip leading/trailing hyphens and dots -- Truncate to 50 characters - -**Prefix enforcement** — post-sanitization: -- Detect the most common prefix from existing branches (`feature/`, `fix/`, etc.) -- If sanitized name has no `/` prefix, prepend the detected convention prefix -- If no convention exists, prepend `worktree/` - -**Validation** — return `nil` (trigger random fallback) if any of these fail: -1. Name duplicates an existing branch (case-insensitive) -2. Name is too short (< 3 characters after sanitization) -3. Name is too long (> 50 characters after sanitization + prefix) -4. Sanitization produced an empty string (garbage input, multi-line output, etc.) - -## Integration into Worktree Creation Flow - -### Prompt path (dialog shown) - -1. User triggers Cmd+N → `createRandomWorktreeInRepository` -2. `.run` effect loads branch refs (existing) + gathers context + calls `suggest` in parallel -3. `promptedWorktreeCreationDataLoaded` → dialog opens with `branchName: ""`, - `isSuggestingName: true`, `randomPlaceholder` pre-generated as random name -4. AI suggestion arrives → `branchNameSuggestionReceived(name)` - - Does NOT auto-fill input field — suggestion only shown in dim hint line below - - Hint line shows "Auto suggestion: {name}" with a "Use" button - - Hover tooltip explains the suggestion source (on-device AI, context-based) -5. `isSuggestingName = false` → loading indicator disappears, suggestion hint visible -6. User clicks "Create" → uses **effective name**: user input if non-empty, else random - placeholder. Empty input no longer blocks creation. - -### Non-prompt path (auto-create without dialog) - -**No change.** When `promptForWorktreeCreation == false`, keep using `nameSource: .random` -directly. This path is designed for instant worktree creation ("don't ask me"); adding AI -latency would violate that intent. AI naming only applies to the dialog path. - -### State Changes in `WorktreeCreationPromptFeature` - -Add to `State`: -- `var isSuggestingName: Bool = false` -- `var suggestedBranchName: String?` — stores the AI suggestion for display -- `let randomPlaceholder: String` — pre-generated random name, shown as placeholder -- Computed `effectiveBranchName: String` — returns `branchName` if non-empty, else - `randomPlaceholder`. Used by submit and path preview. - -Add to `Action`: -- `case branchNameSuggestionReceived(String?)` — AI result arrived -- `case useSuggestedBranchName` — user tapped "Use" button on the hint - -Reducer logic: -- `branchNameSuggestionReceived(name)`: set `isSuggestingName = false`, - store `suggestedBranchName = name`. Does NOT auto-fill `branchName`. -- `useSuggestedBranchName`: copy `suggestedBranchName` into `branchName`. -- `createButtonTapped`: use `effectiveBranchName` instead of `branchName` for validation - and submit. Empty input is no longer an error (falls through to random placeholder). - -### UI Changes in `WorktreeCreationPromptView` - -**Branch name field**: -- Placeholder shows the pre-generated random name (e.g., `bold-cat-042`) -- Empty input is allowed — placeholder name will be used on submit -- Text field remains editable at all times - -**Loading state** (`isSuggestingName == true`): -- Show a subtle `ProgressView` near the text field (trailing overlay) - -**Suggestion hint** (`suggestedBranchName != nil`): -- Below the input field: "Auto suggestion: {name}" in dim/tertiary style -- "Use" button alongside (clicking copies suggestion into input field) -- Hover tooltip: explains this is an on-device AI suggestion based on repo context -- Visible regardless of whether the user has typed anything -- Hidden once user submits (Create) or cancels - -## Files to Create - -| File | Purpose | -|------|---------| -| `supacode/Infrastructure/LLM/LLMService.swift` | Protocol for LLM backends | -| `supacode/Infrastructure/LLM/FoundationModelLLMService.swift` | Foundation Model backend | -| `supacode/Clients/BranchNameSuggestion/BranchNameSuggestionClient.swift` | TCA dependency | -| `supacode/Domain/BranchNameSanitizer.swift` | Branch name sanitization utility | - -## Files to Modify - -| File | Change | -|------|--------| -| `WorktreeCreationPromptFeature.swift` | Add `isSuggestingName`, suggestion action | -| `RepositoriesFeature+WorktreeCreation.swift` | Kick off suggestion in parallel, handle non-prompt path | -| `RepositoriesFeature.swift` | Add `CancelID.branchNameSuggestion`, dependency declaration | -| `WorktreeCreationPromptView.swift` | Loading indicator + suggestion hint with "Use" button | -| `supacodeApp.swift` | Wire `BranchNameSuggestionClient` dependency | -| `WorktreeTerminalState.swift` | Add `lastDefocusedAt: Date?` property | -| `WorktreeTerminalManager.swift` | Set `lastDefocusedAt` on focus-away in `setSelectedWorktreeID` | - -## Verification - -1. `make build-app` — ensure it compiles -2. Run app on macOS 26 with Apple Silicon → Cmd+N → verify AI-suggested name appears -3. Test with other terminal tabs open containing agent sessions → expect contextual name -4. Test with Foundation Model unavailable → expect random adjective-animal-NNN fallback -5. Test typing before suggestion arrives → verify suggestion doesn't overwrite user input -6. Test non-prompt path (setting off) → verify AI name is used instead of random diff --git a/doc-onevcat/scripts/release-to-fork.sh b/doc-onevcat/scripts/release-to-fork.sh deleted file mode 100755 index 58506ec1..00000000 --- a/doc-onevcat/scripts/release-to-fork.sh +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env bash -# DEPRECATED: Use release.sh instead for public releases with Sparkle appcast, -# DMG packaging, and proper versioning. -# This script is kept for reference only. -set -euo pipefail - -origin_repo_from_remote() { - local remote_url - remote_url="$(git remote get-url origin 2>/dev/null || true)" - if [[ -z "${remote_url}" ]]; then - return 1 - fi - - # Supports: - # - git@github.com:owner/repo.git - # - ssh://git@github.com/owner/repo.git - # - https://github.com/owner/repo.git - local repo - repo="$(echo "${remote_url}" | sed -E 's#^(git@github.com:|ssh://git@github.com/|https://github.com/)##; s#\.git$##')" - if [[ "${repo}" == */* ]]; then - echo "${repo}" - return 0 - fi - return 1 -} - -default_signing_identity() { - security find-identity -v -p codesigning 2>/dev/null \ - | awk -F'"' '/Developer ID Application/ {print $2; exit}' -} - -team_id_from_identity() { - local identity="$1" - if [[ "$identity" =~ \(([A-Z0-9]{10})\)$ ]]; then - echo "${BASH_REMATCH[1]}" - fi -} - -submit_with_keychain_profile() { - local artifact_path="$1" - local output - - set +e - output="$(xcrun notarytool submit "$artifact_path" --keychain-profile "$KEYCHAIN_PROFILE" --wait 2>&1)" - local status=$? - set -e - - if [[ $status -eq 0 ]]; then - echo "$output" - return 0 - fi - - echo "$output" >&2 - if [[ "$output" == *"No Keychain password item found for profile"* ]] \ - || [[ "$output" == *"profile"* && "$output" == *"not found"* ]] - then - return 2 - fi - - return $status -} - -store_notary_credentials() { - local key_path="${APPLE_NOTARIZATION_KEY_PATH:-}" - local key_id="${APPLE_NOTARIZATION_KEY_ID:-}" - local issuer="${APPLE_NOTARIZATION_ISSUER:-}" - - if [[ -n "$key_path" || -n "$key_id" || -n "$issuer" ]]; then - if [[ -z "$key_path" || -z "$key_id" || -z "$issuer" ]]; then - echo "error: APPLE_NOTARIZATION_KEY_PATH/KEY_ID/ISSUER must all be set" - exit 1 - fi - if [[ ! -f "$key_path" ]]; then - echo "error: APPLE_NOTARIZATION_KEY_PATH does not exist: $key_path" - exit 1 - fi - xcrun notarytool store-credentials "$KEYCHAIN_PROFILE" \ - --key "$key_path" \ - --key-id "$key_id" \ - --issuer "$issuer" - return - fi - - if [[ -z "$APPLE_ID_INPUT" ]]; then - if [[ -t 0 ]]; then - read -r -p "Apple ID email for notarization: " APPLE_ID_INPUT - else - echo "error: APPLE_ID is required when no key-based notarization credentials are provided" - exit 1 - fi - fi - - if [[ -z "$APPLE_PASSWORD_INPUT" ]]; then - if [[ -t 0 ]]; then - read -r -s -p "App-specific password (input hidden): " APPLE_PASSWORD_INPUT - echo - else - echo "error: APPLE_PASSWORD is required when no key-based notarization credentials are provided" - exit 1 - fi - fi - - if [[ -z "$TEAM_ID_INPUT" ]]; then - TEAM_ID_INPUT="$(team_id_from_identity "$SIGNING_IDENTITY" || true)" - fi - if [[ -z "$TEAM_ID_INPUT" ]]; then - if [[ -t 0 ]]; then - read -r -p "Apple Team ID: " TEAM_ID_INPUT - else - echo "error: APPLE_TEAM_ID is required when it cannot be inferred from signing identity" - exit 1 - fi - fi - - xcrun notarytool store-credentials "$KEYCHAIN_PROFILE" \ - --apple-id "$APPLE_ID_INPUT" \ - --password "$APPLE_PASSWORD_INPUT" \ - --team-id "$TEAM_ID_INPUT" -} - -sign_and_notarize_app() { - local app_path="$1" - local submission_zip="$2" - - echo "[release] codesigning app with identity: $SIGNING_IDENTITY" - codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$app_path" - codesign --verify --deep --strict --verbose=2 "$app_path" - - echo "[release] create notarization artifact: $submission_zip" - ditto -c -k --sequesterRsrc --keepParent "$app_path" "$submission_zip" - - echo "[release] notarizing artifact..." - if submit_with_keychain_profile "$submission_zip"; then - echo "[release] used keychain profile: $KEYCHAIN_PROFILE" - else - local notary_status=$? - if [[ $notary_status -ne 2 ]]; then - exit "$notary_status" - fi - - echo "[release] keychain profile not found: $KEYCHAIN_PROFILE" - echo "[release] storing notarization credentials..." - store_notary_credentials - xcrun notarytool submit "$submission_zip" --keychain-profile "$KEYCHAIN_PROFILE" --wait - fi - - echo "[release] staple notarization ticket to app" - xcrun stapler staple "$app_path" - xcrun stapler validate "$app_path" -} - -if ! command -v gh >/dev/null 2>&1; then - echo "error: gh CLI is required" - exit 1 -fi - -if ! command -v jq >/dev/null 2>&1; then - echo "error: jq is required" - exit 1 -fi - -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "error: this script only supports macOS" - exit 1 -fi - -REPO="${GH_REPO:-$(origin_repo_from_remote || true)}" -if [[ -z "${REPO}" ]]; then - REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner)" -fi - -SHORT_SHA="$(git rev-parse --short HEAD)" -DEFAULT_TAG="onevcat-v$(date +%Y.%m.%d)-${SHORT_SHA}" -TAG="${1:-$DEFAULT_TAG}" -KEYCHAIN_PROFILE="${APPLE_NOTARY_KEYCHAIN_PROFILE:-supacode-notary}" -SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" -TEAM_ID_INPUT="${APPLE_TEAM_ID:-}" -APPLE_ID_INPUT="${APPLE_ID:-}" -APPLE_PASSWORD_INPUT="${APPLE_PASSWORD:-}" - -if [[ "${ENABLE_NOTARIZATION:-1}" != "1" ]]; then - echo "error: publishing non-notarized releases is forbidden for this fork" - echo "error: remove ENABLE_NOTARIZATION=0 and provide notarization credentials" - exit 1 -fi -ENABLE_NOTARIZATION="1" - -echo "[release] repository: ${REPO}" -echo "[release] tag: ${TAG}" -echo "[release] notarization: ${ENABLE_NOTARIZATION}" - -if git rev-parse "${TAG}" >/dev/null 2>&1; then - echo "error: local tag ${TAG} already exists" - exit 1 -fi - -echo "[release] build app" -make build-app - -echo "[release] resolve app path from xcodebuild settings" -SETTINGS="$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)" -BUILD_DIR="$(echo "$SETTINGS" | jq -r '.[0].buildSettings.BUILT_PRODUCTS_DIR')" -PRODUCT_NAME="$(echo "$SETTINGS" | jq -r '.[0].buildSettings.FULL_PRODUCT_NAME')" -APP_PATH="${BUILD_DIR}/${PRODUCT_NAME}" - -if [ ! -d "${APP_PATH}" ]; then - echo "error: app not found at ${APP_PATH}" - exit 1 -fi - -mkdir -p build -ZIP_PATH="build/${PRODUCT_NAME%.app}-${TAG}.app.zip" -NOTES_PATH="build/release-notes-${TAG}.md" -SUBMISSION_ZIP="build/notary-submit-${TAG}.app.zip" -BUILD_TYPE="Debug (Developer ID signed + notarized)" - -if ! command -v xcrun >/dev/null 2>&1; then - echo "error: xcrun is required for notarization" - exit 1 -fi -if ! command -v codesign >/dev/null 2>&1; then - echo "error: codesign is required for notarization" - exit 1 -fi -if [[ -z "$SIGNING_IDENTITY" ]]; then - SIGNING_IDENTITY="$(default_signing_identity || true)" -fi -if [[ -z "$SIGNING_IDENTITY" ]]; then - echo "error: APPLE_SIGNING_IDENTITY is not set and no Developer ID Application identity was found" - exit 1 -fi -sign_and_notarize_app "${APP_PATH}" "${SUBMISSION_ZIP}" - -echo "[release] package ${APP_PATH} -> ${ZIP_PATH}" -ditto -c -k --sequesterRsrc --keepParent "${APP_PATH}" "${ZIP_PATH}" - -UPSTREAM_MAIN_SHA="$(git rev-parse --short upstream/main 2>/dev/null || echo unknown)" -cat > "${NOTES_PATH}" </dev/null 2>&1; then - echo "[release] release already exists, upload asset with --clobber" - gh release upload "${TAG}" "${ZIP_PATH}" --clobber --repo "${REPO}" -else - CREATE_ERR="$(mktemp)" - if gh release create "${TAG}" "${ZIP_PATH}" \ - --repo "${REPO}" \ - --title "Personal build ${TAG}" \ - --notes-file "${NOTES_PATH}" \ - 2>"${CREATE_ERR}" - then - rm -f "${CREATE_ERR}" - else - echo "[release] gh release create failed, fallback to gh api + upload" - cat "${CREATE_ERR}" - rm -f "${CREATE_ERR}" - - if ! gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then - RELEASE_NOTES="$(cat "${NOTES_PATH}")" - PAYLOAD="$(jq -n \ - --arg tag "${TAG}" \ - --arg name "Personal build ${TAG}" \ - --arg body "${RELEASE_NOTES}" \ - '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')" - gh api -X POST "repos/${REPO}/releases" --input - <<<"${PAYLOAD}" >/dev/null - fi - - gh release upload "${TAG}" "${ZIP_PATH}" --clobber --repo "${REPO}" - fi -fi - -echo -echo "[done] release created: https://github.com/${REPO}/releases/tag/${TAG}" diff --git a/doc-onevcat/shelf-view.md b/doc-onevcat/shelf-view.md deleted file mode 100644 index 87cf36f1..00000000 --- a/doc-onevcat/shelf-view.md +++ /dev/null @@ -1,457 +0,0 @@ -# Shelf View - -Last updated: 2026-04-21 -Status: Implemented (see **Implementation Decisions Journal** at the bottom for deviations taken during implementation) - -A new terminal presentation mode that sits alongside Canvas. Where Canvas spreads -worktrees out as flat cards and weakens the worktree concept, Shelf preserves and -strengthens it: each worktree (or plain folder) becomes a "book" with a vertical -spine that doubles as its tab bar. Exactly one book is "open" at any time, -occupying the space between a left stack of already-passed spines and a right -stack of upcoming spines. - ---- - -## Mode & Entry Point - -- Shelf is a terminal-region presentation mode, **mutually exclusive** with - Canvas. The left navigation remains visible in Shelf mode (Shelf only occupies - the terminal region to the right of the navigation). -- The Shelf toggle lives next to the Canvas toggle in the same toolbar `HStack`, - placed immediately to the **right of** (i.e. after) the Canvas entry. -- Toggle hotkey: **`Cmd+Shift+Enter`** — symmetric with `Toggle Canvas`'s - `Cmd+Option+Enter`. -- **Exit Shelf**: only by re-clicking the Shelf toggle (or pressing the toggle - hotkey). Clicking a different worktree in the left navigation does **not** - exit Shelf — it merely changes which book is open. (This differs from Canvas, - where left-nav clicks exit the mode, because Canvas weakens the worktree - concept while Shelf treats `book = worktree` 1:1.) - ---- - -## Concept Mapping - -| Shelf concept | Prowl model | -|---|---| -| Book | A worktree or a plain folder | -| Spine | The book's vertical tab bar; also carries its identity (worktree/folder name + branch) | -| Open book body | The terminal surface (with splits) of the book's currently active tab | - -**Order of books on the shelf** equals the order of worktrees / plain folders in -the left navigation. Reordering happens through the left nav, not on the shelf. - ---- - -## Layout Invariant - -The terminal region (everything to the right of the left navigation) is split -into three horizontal segments: - -``` -[ left spine stack ] [ open book terminal area ] [ right spine stack ] -``` - -Let `N` be the index of the currently open book among all books `1…last`: - -- **Left stack** = spines of books `1…N`, in book order. Book `N`'s spine is the - rightmost in the left stack and sits flush against the left edge of the - terminal area. -- **Terminal area** = the surface of book `N`'s currently active tab (with the - existing split logic). -- **Right stack** = spines of books `N+1…last`, in book order, flush against - the window's right edge. - -**Initial state on entering Shelf**: `N` is the worktree currently identified by -`WorktreeTerminalManager.selectedWorktreeID`; the open book's active tab is -that worktree's currently active tab (no separate Shelf-only tab memory). - -**Book set = opened worktrees/folders only**: the spines shown on the Shelf -are *not* the full list of worktrees + plain folders in the sidebar. The -Shelf only includes books the user has interacted with at least once in -the current session — i.e., those with an associated terminal state. A -worktree that appears in the left navigation but has never been clicked (or -touched by CLI / layout restore) does *not* get a spine. Clicking an -as-yet-unopened worktree in the left navigation while Shelf is active is -what makes its spine materialize — the normal spine-flow animation applies -as the new spine slides into its sidebar-order position. - ---- - -## Spine Specification - -### Geometry - -- **Width**: one line of text (compact, fixed across all spines and across - open/closed states). -- **Identical structure and width whether the book is open or closed**; only - the area to the spine's right changes (terminal surface vs. nothing). - -### Header (top of spine) - -- Worktree name + branch name, rendered **rotated 90°** (vertical reading - direction). -- For **plain folders** (no branch): only the folder name is shown, with the - branch line entirely omitted (consistent with how plain folders are presented - in the left navigation today). -- The header is **not** part of the scrollable area (see Tab List Overflow). - -### Tab List (below header) - -- Each tab is rendered as **its icon only** (Prowl already supports per-tab - custom icons). No label text in the slot. -- Each slot is a uniform-sized clickable target. -- **Hotkey overlay**: when the user holds **⌘ (Command)**, the icon in each - slot is **replaced** by the tab's `Cmd+N` digit (1–9). Slot size and position - do not change — there is zero layout shift. This matches Prowl's existing - "hold ⌘ to reveal hotkeys" behavior. -- For tabs at index ≥ 10 (no `Cmd+N` hotkey): when ⌘ is held, the slot continues - to show the icon (optionally slightly dimmed to hint "no hotkey"); details left - to implementation. - -### Tab List Overflow - -- When the tab list does not fit the available spine height, the **tab list - area scrolls vertically**. -- The header (worktree/branch) stays **pinned** and does not scroll. -- The bottom controls (see below) also stay pinned and do not scroll. - -### Bottom Controls - -- A row of three buttons at the spine's bottom: **`+` / vertical split / - horizontal split**, mirroring Prowl's standard tab bar. -- These controls are **only shown on the spine of the currently open book**. - Closed-book spines do not show them (acting on a non-open book first requires - opening it). - -### Per-Tab Visual States (must all be respected, simultaneously when applicable) - -- **Active tab highlight** — the book's currently selected tab. -- **Notification highlight** — drives off the existing - `WorktreeTerminalState.hasUnseenNotification(for:)`. Visual: **slot - background tint**, using the same color/style as Canvas title-bar - notification highlights (reuse the existing token / style for consistency). - -### Book-Level Aggregated Notification - -- When **any** tab in a book has an unread notification, a **small dot badge** - is shown on the spine **header** (next to the worktree/branch text). -- Purpose: when a notifying tab is scrolled out of view in the spine's tab - list, the user can still see at a glance "this book has activity". -- No directional arrow / no "scroll up to see" hint — keep it minimal. - ---- - -## Open Book Visual Distinction - -The open book's spine is visually distinguished from other spines through a -**combination** of: - -- An **accent color / contrasting background tint** on the open book's spine, - and -- **Visual continuity** with the terminal area: the spine and terminal area - share background color and/or border treatment so the spine reads as "the - left edge of the open page" — reinforcing the book metaphor. - -(Active-tab highlight on the spine's currently-active tab slot is a separate, -**tab-level** signal, independent of the **book-level** open-book signal. -Both can be visible at once.) - ---- - -## Interaction - -### Book ↔ Left Navigation Sync (bidirectional) - -- **Shelf → Left nav**: clicking a different spine in Shelf updates - `selectedWorktreeID` (and therefore the left-nav selection). -- **Left nav → Shelf**: while in Shelf mode, clicking a worktree in the left - navigation triggers the same spine-flow animation as clicking that book's - spine directly. The Shelf does not exit. - -The single source of truth for "which book is open" is `selectedWorktreeID`. - -### Switching Books (clicking a non-open book's spine) - -Clicking spine `M` (where `M ≠ N`): - -- If the click lands on a specific tab slot `T` on spine `M`: animate the - spine flow (rules below), open book `M`, and set `M`'s active tab to `T`. -- If the click lands on the spine **header** only: animate the spine flow, - open book `M`, keep `M`'s previously active tab. - -**Spine flow rules:** - -- **`M > N` (forward)**: spines `N+1…M` slide from the right stack into the - tail of the left stack. Spines `M+1…last` do not move. -- **`M < N` (backward)**: spines `M+1…N` slide from the left stack back to the - head of the right stack. Spines `1…M-1` do not move. - -In both cases the previously open book's spine ends up wherever the flow -places it (no special case). - -### Switching Tabs Within the Open Book - -Clicking a tab slot on the **currently open** book's own spine: - -- **No spine animation, no page-turn transition.** The spine layout is - unchanged. -- The terminal area is replaced with the newly selected tab's surface. - -### Unified Click Rule - -Every tab slot on every spine is a click target meaning "switch to this book -and this tab". Whether the click triggers spine-flow animation depends solely -on whether the targeted book is already the open book. - -### Creating Tabs / Splits - -- Use the **bottom controls** (`+` / vsplit / hsplit) on the **open book's** - spine, or the existing keyboard shortcuts. -- To add a tab to a non-open book: open it first by clicking its spine, then - use the bottom controls. - -### Closing Tabs - -Mirror Prowl's normal-mode tab close behavior: - -- **Hover X**: hovering a tab slot reveals a small X button to close it. -- **Right-click menu**: right-clicking a tab slot opens a tab-level context - menu containing Close (and any other existing tab actions). -- **`Cmd+W`** keyboard shortcut continues to close the active tab. - -### Closing the Last Tab in a Book - -Closing the last tab **retires the book from the Shelf**. Its spine disappears; -if the closed book was the one currently open, Shelf auto-advances to the next -remaining book (in Shelf order). The user can bring the book back by clicking -its worktree in the left navigation, which re-opens it and re-adds its spine -with the standard spine-flow animation. - -(Earlier drafts of this doc proposed keeping the book on the shelf with an -empty-terminal placeholder. Reversed: a lingering empty book felt unnatural and -doubled as dead weight. See the Implementation Decisions Journal for the switch.) - -### Removing a Book from the Shelf - -A book is removed from the shelf only by: - -1. **Closing/removing the worktree** through the left navigation (existing - pathway), or -2. **Right-clicking the spine header** → context menu → **"Remove book"**. - -Right-click scoping: - -- Right-click on a **tab slot** → tab-level context menu (Close, etc.). -- Right-click on the **spine header or its empty body area** → book-level - context menu (Remove book, etc.). - ---- - -## Animation Specification - -### Axis 1 — Spine flow character - -- **Snappy**: ~200ms, ease-in-out. Crisp, minimal hang time. - -### Axis 2 — Terminal area swap - -- Use SwiftUI **`matchedGeometryEffect`** (or the closest equivalent): the - terminal area is treated as a piece of "openable book content" that - geometrically transforms together with its spine. -- During transitions, **two terminals may coexist briefly** in the terminal - region: - - **Forward (`M > N`, "pulling in")**: book `M`'s terminal slides in from - the right alongside `M`'s spine. The previously open book `N`'s terminal - stays in place and **fades out** as `M`'s terminal arrives, so the user - never sees a half-clipped or partially-replaced surface. - - **Backward (`M < N`, "pushing out")**: book `N`'s terminal slides out to - the right alongside `N`'s spine and **fades out** during the slide. Book - `M`'s terminal materializes at its destination (slide-in or fade-in, as - looks best in implementation). -- **Unified rule**: the "about to be invisible" terminal handles the fade; the - "about to be visible" terminal stays opaque (slide-in) or fades in. This - prevents surface views from popping in / out abruptly and avoids visual - tears against the moving spines. - ---- - -## Keyboard Shortcuts - -All Shelf-related shortcuts are **configurable** through Prowl's existing -keybinding system (`scope = configurableAppAction`), exposed in -`Settings → Shortcuts`. - -| Command | Default binding | Notes | -|---|---|---| -| `toggleShelf` | `Cmd+Shift+Enter` | New command. Symmetric with `toggleCanvas` (`Cmd+Option+Enter`). | -| `selectTerminalTab1…9` | `Cmd+1..9` | **Existing** commands. In Shelf, they switch tabs within the open book. | -| `selectPreviousTerminalTab` / `selectNextTerminalTab` | `Cmd+Shift+[` / `Cmd+Shift+]` | **Existing** — apply within the open book. | -| `selectNextWorktree` / `selectPreviousWorktree` | `Cmd+Ctrl+↓` / `Cmd+Ctrl+↑` | Mode-aware: outside Shelf, cycles worktrees (unchanged). Inside Shelf, reroutes to tab navigation within the open book — vertical arrows step through tabs on the spine, horizontal arrows step through books, matching the Shelf's two-axis layout. See `selectNext/PreviousShelfBook` below for the `Cmd+Ctrl+→` / `Cmd+Ctrl+←` bindings. | -| `selectNextShelfBook` / `selectPreviousShelfBook` | `Cmd+Ctrl+→` / `Cmd+Ctrl+←` | **New commands**. Operate on the ordered Shelf-book list (worktrees + plain folders), which can diverge from the worktree list if plain folders are interleaved. See the Implementation Decisions Journal for why we took this over a two-binding alias on the worktree commands. | -| `selectShelfBook1…9` | `Ctrl+Option+1..9` | **New commands**, deliberately distinct from `selectWorktree1..9` (`Ctrl+1..9`). Books and worktrees are not 1:1 in numbering: "books on the shelf" can diverge from "items in the left navigation" (e.g. presence/absence on the shelf, plain-folder ordering). Shelf-specific. | - -### Implementation note on multi-binding - -The current `KeybindingSchema` / `AppShortcut` / `Binding` model holds a single -`shortcut` per command. The `Cmd+Ctrl+←/→` alias for -`selectNext/PreviousWorktree` requires a non-trivial extension to support a -collection of bindings per command (and to surface that in the settings UI). -If this cost proves prohibitive, the fallback is to introduce wrapper commands -(e.g. `selectNextBookAlias`) that invoke the same underlying action, at the -cost of duplicating rows in the shortcuts settings list. - ---- - -## Mapping to Existing Models - -- The ordered list of spines mirrors the ordered list of worktrees + plain - folders tracked by `WorktreeTerminalManager`. -- Each spine's tab list mirrors that worktree's `TerminalTabManager` tabs. -- The terminal area renders the active tab's `GhosttySurfaceState` (and any - splits) using the existing surface-rendering path. -- `WorktreeTerminalManager.selectedWorktreeID` ↔ "the open book", driven by - spine clicks and left-nav clicks alike (single source of truth). -- Per-spine tab-slot notification highlights consume - `WorktreeTerminalState.hasUnseenNotification(for:)`. -- Per-book aggregated header dot consumes - `WorktreeTerminalState.hasUnseenNotification` (book-wide). - ---- - -## Open Implementation Questions (non-blocking) - -- Spine height budget per slot, and the exact dimming treatment for tabs ≥ 10 - when ⌘ is held. -- Exact accent color / continuity treatment for the open book's spine + terminal - area (decide during visual implementation; iterate if it looks off). -- Whether the spine should auto-scroll to reveal a newly-arriving notification - (vs. relying solely on the aggregated header dot). -- Multi-binding architectural change (see Keyboard Shortcuts → Implementation - note) — design before implementation. -- Empty-state visuals for an empty Shelf (no books at all). -- Animation behavior under user interruption (e.g. clicking a third spine while - a transition is mid-flight). - ---- - -## Implementation Decisions Journal - -Decisions made during implementation that deviate from — or add nuance to — -the earlier design, recorded for review. - -### Keyboard Shortcuts: wrapper commands over multi-binding - -**Design spec** had `Cmd+Ctrl+→` / `Cmd+Ctrl+←` as a second alias on the -existing `selectNext/PreviousWorktree` commands, with a note that this -requires a non-trivial extension to the keybinding schema (singular -`shortcut` → collection). - -**Implemented** as distinct `selectNextShelfBook` / `selectPreviousShelfBook` -commands. Reasons: - -- The Shelf-book ordering includes plain folders (interleaved per - `orderedShelfBooks()`), so "next book on the Shelf" is not semantically - equal to "next worktree" when plain folders exist. Aliasing would have - skipped plain folders when a user pressed the arrow alias. -- The wrapper-command approach keeps `AppShortcut.Binding.shortcut` singular, - avoiding the schema change. -- Both commands still live in `Settings → Shortcuts` so users can remap - either set independently. - -### Commands plumbing: merged into `SidebarCommands` - -Originally planned as a separate `ShelfCommands: Commands` struct. Moved into -`SidebarCommands` because SwiftUI's `@CommandsBuilder` caps the number of -direct children in a `.commands { }` block; adding a new top-level Commands -struct pushed the builder past the cap and triggered a compile error on -unrelated `CommandGroup`s. Merging keeps the external menu footprint the -same (two visible toggles + one Worktrees menu). - -### `isShelfActive` as a separate flag - -`RepositoriesFeature.State` gained a new `isShelfActive: Bool` flag instead -of adding a `.shelf` case to `SidebarSelection`. Reason: Shelf is a -presentation mode that still needs `selection` to track a worktree or plain -folder (the open book). Using a dedicated flag decouples "is Shelf active" -from "which book is open", which lets the bidirectional sync with the left -navigation fall out for free. - -### Auto-exit rules - -Entering Canvas or Archived Worktrees from any entry point clears -`isShelfActive` — those two presentation modes are mutually exclusive with -Shelf by design. Entering Shelf from Canvas / archived redirects selection -to a compatible worktree / plain-folder before flipping the flag. - -### Terminal rendering in the open area - -Rather than reusing `WorktreeTerminalTabsView` (which includes the horizontal -tab bar), we introduced `ShelfOpenBookView` — a leaner view that renders only -the terminal content stack + icon picker sheet + window focus observer. In -Shelf, the tab bar lives on the spine, so duplicating it would violate the -design. - -### Plain folder spines - -`ShelfBook` uses `Worktree.ID` as its identity. For plain folders this is -the repository ID, matching the synthetic worktree emitted by -`RepositoriesFeature.State.selectedTerminalWorktree`. That way -`openShelfBookID == selectedTerminalWorktree?.id` for both kinds without -special-casing. - -### Animation: `.animation(value:)` for both entry points - -To make left-nav-originated book switches animate identically to -Shelf-originated taps, the root `HStack` carries an explicit -`.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier. -Shelf-originated taps additionally pass the same animation to -`store.send(_, animation:)` so the TCA-side mutation carries the transaction -along. - -### Close-last-tab behavior (revised) - -Reversed from the original decision. Closing the last tab now removes the -book from the Shelf entirely. The implementation: - -- `TerminalClient.Event.tabClosed` gained a `remainingTabs: Int` payload so - AppFeature can detect the last-tab case. When it sees `remainingTabs == 0`, - it dispatches `.repositories(.markWorktreeClosed(id))`. -- The `markWorktreeClosed` reducer handler removes the ID from - `openedWorktreeIDs`, and — only when Shelf is active and the closed - worktree was the open book — auto-advances selection to the next - remaining book (via `shelfBookSelectionEffect`). In normal view, the - selection is left alone so the user's current context isn't disturbed. -- When the closed book was the last book on the Shelf, selection is kept - as-is; `ShelfView` falls through to its "No book selected" empty state. - -### Opened-worktrees set - -`RepositoriesFeature.State.openedWorktreeIDs: Set` tracks -which worktrees/plain folders are currently part of the Shelf's book list. -It's updated by the reducer in four places: - -- `.selectWorktree(id, _)` handler inserts `id` (covers sidebar click, - Shelf click, most CLI opens, layout restore of the *active* worktree) -- `.selectRepository(id)` handler inserts `id` when the repository is a - plain folder -- `.toggleShelf` entry path inserts the currently selected ID when the - selection is already compatible with Shelf (rare path, but guards - against state set without going through the two actions above) -- `.markWorktreeOpened(id)` — a dedicated action that AppFeature - dispatches in response to `.terminalEvent(.tabCreated(worktreeID:))`. - This is the *critical* catch-all for every path that sets - `state.selection` directly without going through `.selectWorktree` — - in particular, the cold-launch auto-selection that restores the last - focused worktree (`state.selection = state.lastFocusedWorktreeID…` in - `applyRepositories`) and the "first available after reload" fallback. - Layout restore is a third such path. The common downstream signal in - all of them is that the newly-focused worktree ends up materializing - its first tab, emitting `.tabCreated`, which this forwarder converts - into an `openedWorktreeIDs` insertion. - -`orderedShelfBooks()` filters against this set. The set is pure -additive in this iteration — archived / removed worktrees still drop off -the Shelf because the book iteration is anchored on the live -`repositories` array, not on `openedWorktreeIDs`. That leaves a handful -of stale IDs in the set but no visible spines; pruning can be layered in -later if the set grows unbounded. diff --git a/doc-onevcat/fork-sync-and-release.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md similarity index 91% rename from doc-onevcat/fork-sync-and-release.md rename to docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md index ec1661dd..661ed5c9 100644 --- a/doc-onevcat/fork-sync-and-release.md +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md @@ -1,5 +1,7 @@ # Fork Sync and Public Release Workflow +> Living document of entry 001. Migrated from `doc-onevcat/fork-sync-and-release.md` on 2026-07-12; update in place. + ## Goal Keep `onevcat/Prowl` close to `supabitapp/supacode` while preserving local customizations, and publish public releases with Sparkle auto-update support. @@ -59,7 +61,7 @@ If conflicts happen, resolve once, commit, and `rerere` will likely auto-apply n ## Ghostty Submodule Sync Prowl carries a small Ghostty fork patch for embedded APIs. Before changing or upgrading `ThirdParty/ghostty`, read -`doc-onevcat/fork-sync-ghostty.md`. +`docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md`. The submodule should point at `onevcat/ghostty` patched branches named `release/v-patched`. After moving the submodule pointer, run: @@ -75,10 +77,10 @@ make build-app ```bash # Run the release script (defaults to today's date as version) -./doc-onevcat/scripts/release.sh +./scripts/release.sh # Or specify version explicitly -./doc-onevcat/scripts/release.sh 2026.3.18 +./scripts/release.sh 2026.3.18 ``` Or use the `/release` command. @@ -169,14 +171,14 @@ sentry-cli debug-files upload --include-sources To skip the entire Sentry block (e.g. emergency release, sentry-cli not installed): ```bash -SKIP_SENTRY=1 ./doc-onevcat/scripts/release.sh +SKIP_SENTRY=1 ./scripts/release.sh ``` ## Helper Scripts -- `doc-onevcat/scripts/sync-upstream-main.sh` — upstream sync automation -- `doc-onevcat/scripts/release.sh` — full public release pipeline -- `doc-onevcat/scripts/release-to-fork.sh` — (deprecated) legacy personal release script +- `scripts/sync-upstream-main.sh` — upstream sync automation +- `scripts/release.sh` — full public release pipeline +- `release-to-fork.sh` — (deprecated) legacy personal release script; removed in the 2026-07 docs-ai migration ## Common Pitfalls diff --git a/doc-onevcat/fork-sync-ghostty.md b/docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md similarity index 97% rename from doc-onevcat/fork-sync-ghostty.md rename to docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md index d66a83a7..35aec5c3 100644 --- a/doc-onevcat/fork-sync-ghostty.md +++ b/docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md @@ -1,5 +1,7 @@ # Ghostty Fork Sync +> Living document of entry 007. Migrated from `doc-onevcat/fork-sync-ghostty.md` on 2026-07-12; update in place. + Prowl embeds GhosttyKit from `ThirdParty/ghostty`. The submodule points to the `onevcat/ghostty` fork so Prowl can carry small embedded API patches that are not yet upstream. ## Branch Model diff --git a/doc-onevcat/keybinding-system.md b/docs-ai/012-keybinding-system/architecture.md similarity index 98% rename from doc-onevcat/keybinding-system.md rename to docs-ai/012-keybinding-system/architecture.md index df40b360..3cc3dbd0 100644 --- a/doc-onevcat/keybinding-system.md +++ b/docs-ai/012-keybinding-system/architecture.md @@ -1,5 +1,7 @@ # Keybinding System +> Living document of entry 012. Migrated from `doc-onevcat/keybinding-system.md` on 2026-07-12; update in place. + Guide for agents working on keyboard shortcuts in Prowl. ## Architecture Overview diff --git a/doc-onevcat/contracts/cli/architecture.md b/docs-ai/013-prowl-cli/contracts/architecture.md similarity index 96% rename from doc-onevcat/contracts/cli/architecture.md rename to docs-ai/013-prowl-cli/contracts/architecture.md index 1ef4edac..4f0be0e8 100644 --- a/doc-onevcat/contracts/cli/architecture.md +++ b/docs-ai/013-prowl-cli/contracts/architecture.md @@ -1,5 +1,7 @@ # CLI Architecture & App Interaction Plan (Phase 1) +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/architecture.md` on 2026-07-12; update in place. + Status: implementation plan for `#70` after contract alignment. This plan defines where CLI logic lives, how requests are transported to a running app, and how command execution is routed inside Prowl. @@ -11,7 +13,7 @@ This plan defines where CLI logic lives, how requests are transported to a runni - Make `prowl` a stable machine interface for a running Prowl instance. - Keep parsing and validation outside app runtime logic. - Reuse existing repository/terminal capabilities instead of rebuilding terminal core. -- Align runtime behavior with contract docs under `doc-onevcat/contracts/cli/`. +- Align runtime behavior with contract docs under `docs-ai/013-prowl-cli/contracts/`. --- diff --git a/doc-onevcat/contracts/cli/focus.md b/docs-ai/013-prowl-cli/contracts/focus.md similarity index 97% rename from doc-onevcat/contracts/cli/focus.md rename to docs-ai/013-prowl-cli/contracts/focus.md index 675d271d..309d1b75 100644 --- a/doc-onevcat/contracts/cli/focus.md +++ b/docs-ai/013-prowl-cli/contracts/focus.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl focus` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/focus.md` on 2026-07-12; update in place. + Status: draft truth source for `#66`. This file defines the **JSON output contract** for: diff --git a/doc-onevcat/contracts/cli/input.md b/docs-ai/013-prowl-cli/contracts/input.md similarity index 97% rename from doc-onevcat/contracts/cli/input.md rename to docs-ai/013-prowl-cli/contracts/input.md index dea3d060..1d12c95d 100644 --- a/doc-onevcat/contracts/cli/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -1,5 +1,7 @@ # CLI Input Contract: `prowl` (v1) +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/input.md` on 2026-07-12; update in place. + Status: draft truth source for `#70` implementation. This file defines **input-side** rules for the phase-1 CLI commands: @@ -11,7 +13,7 @@ This file defines **input-side** rules for the phase-1 CLI commands: - `key` - `read` -It complements output contracts under `doc-onevcat/contracts/cli/{open,list,focus,send,key,read}.md`. +It complements output contracts under `docs-ai/013-prowl-cli/contracts/{open,list,focus,send,key,read}.md`. --- diff --git a/doc-onevcat/contracts/cli/key.md b/docs-ai/013-prowl-cli/contracts/key.md similarity index 98% rename from doc-onevcat/contracts/cli/key.md rename to docs-ai/013-prowl-cli/contracts/key.md index cd3fbf5b..47021afb 100644 --- a/doc-onevcat/contracts/cli/key.md +++ b/docs-ai/013-prowl-cli/contracts/key.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl key` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/key.md` on 2026-07-12; update in place. + Status: draft truth source for `#68`. This file defines the **input/output contract** for: diff --git a/doc-onevcat/contracts/cli/list.md b/docs-ai/013-prowl-cli/contracts/list.md similarity index 97% rename from doc-onevcat/contracts/cli/list.md rename to docs-ai/013-prowl-cli/contracts/list.md index 5ae82b34..2e53ee9b 100644 --- a/doc-onevcat/contracts/cli/list.md +++ b/docs-ai/013-prowl-cli/contracts/list.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl list` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/list.md` on 2026-07-12; update in place. + Status: draft truth source for `#65`. This file defines the **JSON output contract** for: diff --git a/doc-onevcat/contracts/cli/open.md b/docs-ai/013-prowl-cli/contracts/open.md similarity index 97% rename from doc-onevcat/contracts/cli/open.md rename to docs-ai/013-prowl-cli/contracts/open.md index c3684e86..923daced 100644 --- a/doc-onevcat/contracts/cli/open.md +++ b/docs-ai/013-prowl-cli/contracts/open.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl open` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/open.md` on 2026-07-12; update in place. + Status: draft truth source for `#64`. This file defines the **JSON output contract** for the path-opening entry points: diff --git a/doc-onevcat/contracts/cli/read.md b/docs-ai/013-prowl-cli/contracts/read.md similarity index 97% rename from doc-onevcat/contracts/cli/read.md rename to docs-ai/013-prowl-cli/contracts/read.md index 0832a86a..854feae9 100644 --- a/doc-onevcat/contracts/cli/read.md +++ b/docs-ai/013-prowl-cli/contracts/read.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl read` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/read.md` on 2026-07-12; update in place. + Status: draft truth source for `#69`. This file defines the **JSON output contract** for: diff --git a/doc-onevcat/contracts/cli/schema.md b/docs-ai/013-prowl-cli/contracts/schema.md similarity index 99% rename from doc-onevcat/contracts/cli/schema.md rename to docs-ai/013-prowl-cli/contracts/schema.md index c8a89759..c43e9c20 100644 --- a/doc-onevcat/contracts/cli/schema.md +++ b/docs-ai/013-prowl-cli/contracts/schema.md @@ -1,5 +1,7 @@ # Prowl CLI JSON Schema Definitions (v1) +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/schema.md` on 2026-07-12; update in place. + Status: draft truth source for #96. This file provides machine-validatable JSON Schema definitions for the v1 CLI output contracts described in: diff --git a/doc-onevcat/contracts/cli/send.md b/docs-ai/013-prowl-cli/contracts/send.md similarity index 98% rename from doc-onevcat/contracts/cli/send.md rename to docs-ai/013-prowl-cli/contracts/send.md index c2f071fc..8fb4afc3 100644 --- a/doc-onevcat/contracts/cli/send.md +++ b/docs-ai/013-prowl-cli/contracts/send.md @@ -1,5 +1,7 @@ # CLI Contract: `prowl send` +> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/send.md` on 2026-07-12; update in place. + Status: draft truth source for `#67`. This file defines the **JSON output contract** for: diff --git a/doc-onevcat/plans/2026-07-06-upstream-sync-batch.md b/docs-ai/017-upstream-sync-process/batch-2026-07-06-post-v0.10.5.md similarity index 96% rename from doc-onevcat/plans/2026-07-06-upstream-sync-batch.md rename to docs-ai/017-upstream-sync-process/batch-2026-07-06-post-v0.10.5.md index ebbf6075..8b8dbbc0 100644 --- a/doc-onevcat/plans/2026-07-06-upstream-sync-batch.md +++ b/docs-ai/017-upstream-sync-process/batch-2026-07-06-post-v0.10.5.md @@ -1,11 +1,13 @@ # 2026-07-06 Upstream Sync Batch — Investigation Record & Decisions +> Historical record of entry 017. Kept verbatim from `doc-onevcat/plans/2026-07-06-upstream-sync-batch.md` (migrated 2026-07-12). + Scope: upstream `supabitapp/supacode` commits after baseline `1d888dbc` (2026-06-05, post-v0.10.2) through `bcbc4059` (2026-07-06), spanning v0.10.3 → v0.10.5. 70 commits total. This document records the per-commit verdicts from the 2026-07-06 investigation round and the resulting fork actions. Each "port" theme lands as its own PR referencing this plan. The -`doc-onevcat/change-list.md` baseline should be advanced to `bcbc4059` only after the PRs from this +`docs-ai/017-upstream-sync-process/upstream-ledger.md` baseline should be advanced to `bcbc4059` only after the PRs from this batch have merged. ## Verdict: already fixed / already present in fork (no action) @@ -98,5 +100,5 @@ staged adoption options (MVP → editors → reconnect loop). 8. Remote SSH track → deferred to **Linear CLAW-98** 9. This plan document (its own docs PR) -After all PRs merge: advance `doc-onevcat/change-list.md` baseline to `bcbc4059` (2026-07-06) with a +After all PRs merge: advance `docs-ai/017-upstream-sync-process/upstream-ledger.md` baseline to `bcbc4059` (2026-07-06) with a new dated entry summarizing this round (ported PRs, skipped tracks, and the two Linear follow-ups). diff --git a/doc-onevcat/change-list.md b/docs-ai/017-upstream-sync-process/upstream-ledger.md similarity index 98% rename from doc-onevcat/change-list.md rename to docs-ai/017-upstream-sync-process/upstream-ledger.md index d540ffb7..f51ebd29 100644 --- a/doc-onevcat/change-list.md +++ b/docs-ai/017-upstream-sync-process/upstream-ledger.md @@ -1,5 +1,7 @@ # Fork Change Log +> Living document of entry 017. Migrated from `doc-onevcat/change-list.md` on 2026-07-12; update in place. + ## Upstream Baseline | Key | Value | @@ -20,7 +22,7 @@ Future upstream checks should only inspect commits **after** this baseline. Reviewed 70 commits on `supabitapp/supacode` from `1d888dbc` (post-v0.10.2, 2026-06-05) through `bcbc4059` (post-v0.10.5, 2026-07-06), spanning upstream releases v0.10.3 → v0.10.5. Per-commit verdicts and decision rationale are recorded in -`doc-onevcat/plans/2026-07-06-upstream-sync-batch.md` (PR #547). The fork is now aligned to this +`docs-ai/017-upstream-sync-process/batch-2026-07-06-post-v0.10.5.md` (PR #547). The fork is now aligned to this tip; the next `/check-upstream-changes` run only needs to diff against `bcbc4059`. ### Ported into the fork @@ -183,7 +185,7 @@ Flat list of all 49 non-release commits in range, for cross-reference: - Created `onevcat/ghostty` fork branch `release/v1.3.1-patched` from upstream tag `v1.3.1`. - Added fork-only embedded C API `ghostty_surface_pid(ghostty_surface_t)` for per-pane agent process detection. - Prowl submodule now tracks the patched fork branch. Upgrade procedure is documented in - `doc-onevcat/fork-sync-ghostty.md`. + `docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md`. --- diff --git a/doc-onevcat/observability.md b/docs-ai/020-observability/runbook.md similarity index 97% rename from doc-onevcat/observability.md rename to docs-ai/020-observability/runbook.md index fc09fbdd..070519de 100644 --- a/doc-onevcat/observability.md +++ b/docs-ai/020-observability/runbook.md @@ -1,5 +1,8 @@ # Observability Runbook +> Living document of entry 020. Migrated from `doc-onevcat/observability.md` on 2026-07-12; update in place. +> Known drift (2026-07-12): the App-Hang tracking sections below describe machinery removed in #236/#241 (SentryEventFilter is deleted); pending an update pass — see 001-action.md. + Quick-start reference for investigating production issues using the Sentry + PostHog pipelines built into Prowl. When you (or an agent) come back to diagnose something, read this first — it's the map. ## TL;DR @@ -176,7 +179,7 @@ Disabled via `captureApplicationLifecycleEvents = false` + `captureScreenViews = | Memory probe (`phys_footprint`) | `supacode/Support/MemoryProbe.swift` | | Memory watchdog (baseline + thresholds) | `supacode/Support/MemoryWatchdog.swift` | | System hang filter | `supacode/Support/SentryEventFilter.swift` | -| Release pipeline (dSYM upload + release tracking) | `doc-onevcat/scripts/release.sh` | +| Release pipeline (dSYM upload + release tracking) | `scripts/release.sh` | | Credentials template | `Config/Secrets.env.template` | ## Quick reference: making changes diff --git a/doc-onevcat/shelf-jank-investigation.md b/docs-ai/023-shelf-mode/jank-investigation.md similarity index 99% rename from doc-onevcat/shelf-jank-investigation.md rename to docs-ai/023-shelf-mode/jank-investigation.md index dda5be07..99bbf7b9 100644 --- a/doc-onevcat/shelf-jank-investigation.md +++ b/docs-ai/023-shelf-mode/jank-investigation.md @@ -1,5 +1,7 @@ # Shelf Book-Switch Jank Investigation +> Historical record of entry 023. Kept verbatim from `doc-onevcat/shelf-jank-investigation.md` (migrated 2026-07-12). + Last updated: 2026-04-29 Status: Closed for now. The current branch keeps the fixes that improved trace data or UX without visible regressions, and drops the later experiments that introduced artifacts or reduced interaction quality. diff --git a/doc-onevcat/agent-session-detection.md b/docs-ai/045-native-agent-session-detection/research-cli-session-identity.md similarity index 99% rename from doc-onevcat/agent-session-detection.md rename to docs-ai/045-native-agent-session-detection/research-cli-session-identity.md index 92a6bd06..722d39b6 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/docs-ai/045-native-agent-session-detection/research-cli-session-identity.md @@ -1,5 +1,7 @@ # Agent Session Detection +> Historical record of entry 045. Kept verbatim from `doc-onevcat/agent-session-detection.md` (migrated 2026-07-12). + ## Purpose Prowl resolves a detected terminal agent process to its native session metadata without requiring hooks. This is a diff --git a/doc-onevcat/scripts/release-notes.sh b/scripts/release-notes.sh similarity index 98% rename from doc-onevcat/scripts/release-notes.sh rename to scripts/release-notes.sh index a37b8315..005a2a4e 100755 --- a/doc-onevcat/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Generate release notes for the next Prowl version. # -# Usage: ./doc-onevcat/scripts/release-notes.sh [VERSION] +# Usage: ./scripts/release-notes.sh [VERSION] # # Compares HEAD against the previous release tag, gathers commits and PR # descriptions, and uses an LLM (claude CLI) to produce user-facing release @@ -247,4 +247,4 @@ if ! lint_release_notes "$NOTES_FILE"; then fi log "review and edit the file if needed, then run:" -log " ./doc-onevcat/scripts/release.sh $VERSION" +log " ./scripts/release.sh $VERSION" diff --git a/doc-onevcat/scripts/release.sh b/scripts/release.sh similarity index 99% rename from doc-onevcat/scripts/release.sh rename to scripts/release.sh index 2317ad54..f575fd07 100755 --- a/doc-onevcat/scripts/release.sh +++ b/scripts/release.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Prowl release script: bump, build, sign, notarize, and publish. # -# Usage: ./doc-onevcat/scripts/release.sh [VERSION] +# Usage: ./scripts/release.sh [VERSION] # # Prerequisites: -# Run ./doc-onevcat/scripts/release-notes.sh first to generate and review +# Run ./scripts/release-notes.sh first to generate and review # build/release-notes.md. This script will refuse to proceed without it. # # Environment variables: diff --git a/doc-onevcat/scripts/sync-upstream-main.sh b/scripts/sync-upstream-main.sh similarity index 100% rename from doc-onevcat/scripts/sync-upstream-main.sh rename to scripts/sync-upstream-main.sh -- 2.51.2 From b58c83114c79b832a911df02e7cdb38178920c9c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 12 Jul 2026 12:45:03 +0900 Subject: [PATCH 2/2] docs: backfill docs-ai work records (45 entries) and add write-ai-doc skill Reconstruct spec-driven records for every fork feature and decision-shaping fix since 2026-02-26: each docs-ai/NNN-/ entry holds a retrospective 000-plan.md, a code-verified 001-action.md, and amendment files for later waves. Adds the docs-ai README index, an aggregated backfill-open-questions list, the write-ai-doc skill for future entries, and the AGENTS.md rules pointing agents at both. --- .claude/skills/write-ai-doc/SKILL.md | 133 +++++++++++++++++ AGENTS.md | 3 +- .../000-plan.md | 115 +++++++++++++++ .../001-action.md | 84 +++++++++++ .../002-homebrew-cask-automation.md | 39 +++++ .../003-appcast-from-github-releases.md | 32 +++++ docs-ai/002-custom-commands/000-plan.md | 76 ++++++++++ docs-ai/002-custom-commands/001-action.md | 80 +++++++++++ ...02-ui-revamp-and-keybinding-unification.md | 46 ++++++ .../003-split-target-and-close-on-success.md | 44 ++++++ docs-ai/003-diff-window/000-plan.md | 97 +++++++++++++ docs-ai/003-diff-window/001-action.md | 70 +++++++++ .../002-external-diff-tools.md | 39 +++++ .../003-render-pipeline-hardening.md | 68 +++++++++ .../004-appearance-follows-app.md | 47 ++++++ docs-ai/004-prowl-rebrand/000-plan.md | 95 ++++++++++++ docs-ai/004-prowl-rebrand/001-action.md | 70 +++++++++ .../002-migration-copy-not-move.md | 33 +++++ docs-ai/005-canvas-live-sessions/000-plan.md | 99 +++++++++++++ .../005-canvas-live-sessions/001-action.md | 75 ++++++++++ .../002-cmd-w-close-semantics.md | 36 +++++ docs-ai/006-startup-performance/000-plan.md | 92 ++++++++++++ docs-ai/006-startup-performance/001-action.md | 73 ++++++++++ .../002-upstream-contribution.md | 29 ++++ .../000-plan.md | 105 ++++++++++++++ .../001-action.md | 72 ++++++++++ .../002-theme-appearance-sync.md | 54 +++++++ .../003-text-and-key-event-safety.md | 55 +++++++ .../008-terminal-notifications/000-plan.md | 99 +++++++++++++ .../008-terminal-notifications/001-action.md | 65 +++++++++ .../002-notification-jump-and-indicators.md | 35 +++++ ...oolbar-dock-options-and-stuck-indicator.md | 54 +++++++ ...04-sound-picker-and-viewed-surface-mute.md | 74 ++++++++++ .../000-plan.md | 105 ++++++++++++++ .../001-action.md | 67 +++++++++ .../002-surface-resource-waste.md | 50 +++++++ docs-ai/010-plain-folder-support/000-plan.md | 97 +++++++++++++ .../010-plain-folder-support/001-action.md | 76 ++++++++++ .../002-plain-upgrade-watchers.md | 54 +++++++ .../000-plan.md | 118 +++++++++++++++ .../001-action.md | 80 +++++++++++ docs-ai/012-keybinding-system/000-plan.md | 94 ++++++++++++ docs-ai/012-keybinding-system/001-action.md | 62 ++++++++ .../002-ghostty-key-equivalent-ownership.md | 32 +++++ .../003-chained-binding-hint-limitation.md | 43 ++++++ docs-ai/013-prowl-cli/000-plan.md | 122 ++++++++++++++++ docs-ai/013-prowl-cli/001-action.md | 119 +++++++++++++++ docs-ai/013-prowl-cli/002-agents-command.md | 48 +++++++ .../000-plan.md | 105 ++++++++++++++ .../001-action.md | 80 +++++++++++ .../002-launch-restore-races.md | 42 ++++++ .../000-plan.md | 89 ++++++++++++ .../001-action.md | 61 ++++++++ .../002-split-large-swift-files.md | 41 ++++++ .../003-worktree-stream-request-api.md | 36 +++++ .../016-dev-build-and-ci-workflow/000-plan.md | 93 ++++++++++++ .../001-action.md | 93 ++++++++++++ .../002-format-lint-alignment.md | 52 +++++++ .../003-ci-throughput-and-caching.md | 68 +++++++++ .../004-debug-identity-and-dev-loop.md | 53 +++++++ docs-ai/017-upstream-sync-process/000-plan.md | 93 ++++++++++++ .../017-upstream-sync-process/001-action.md | 53 +++++++ .../002-plan-doc-batch-workflow.md | 45 ++++++ docs-ai/018-archived-worktrees/000-plan.md | 92 ++++++++++++ docs-ai/018-archived-worktrees/001-action.md | 72 ++++++++++ .../002-archived-button-toggle.md | 39 +++++ .../000-plan.md | 88 ++++++++++++ .../001-action.md | 85 +++++++++++ .../002-worktree-history-navigation.md | 47 ++++++ .../003-safe-branch-deletion-and-cleanup.md | 50 +++++++ .../004-advanced-placement-overrides.md | 56 ++++++++ .../005-add-to-prowl-clone.md | 34 +++++ docs-ai/020-observability/000-plan.md | 97 +++++++++++++ docs-ai/020-observability/001-action.md | 73 ++++++++++ .../020-observability/002-app-hang-removal.md | 61 ++++++++ .../003-identity-and-network-noise.md | 34 +++++ docs-ai/021-sparkle-update-ux/000-plan.md | 78 ++++++++++ docs-ai/021-sparkle-update-ux/001-action.md | 67 +++++++++ .../002-sparkle-292-and-driver-isolation.md | 44 ++++++ .../003-background-update-downloads.md | 35 +++++ .../004-install-confirmation.md | 37 +++++ docs-ai/022-tab-title-and-icon/000-plan.md | 102 +++++++++++++ docs-ai/022-tab-title-and-icon/001-action.md | 70 +++++++++ .../002-auto-detected-icons.md | 60 ++++++++ .../003-persistent-custom-titles.md | 39 +++++ docs-ai/023-shelf-mode/000-plan.md | 121 ++++++++++++++++ docs-ai/023-shelf-mode/001-action.md | 74 ++++++++++ .../023-shelf-mode/002-book-switch-jank.md | 51 +++++++ .../003-agent-status-and-trackpad.md | 51 +++++++ .../000-plan.md | 116 +++++++++++++++ .../001-action.md | 97 +++++++++++++ .../002-first-class-canvas.md | 52 +++++++ .../003-keyboard-and-layout-wave.md | 74 ++++++++++ .../004-completeness-wave.md | 56 ++++++++ .../025-repo-identity-appearance/000-plan.md | 102 +++++++++++++ .../001-action.md | 90 ++++++++++++ .../002-upstream-divergence.md | 45 ++++++ .../000-plan.md | 115 +++++++++++++++ .../001-action.md | 97 +++++++++++++ .../002-add-repository-entry-point.md | 44 ++++++ docs-ai/027-split-pane-ux/000-plan.md | 89 ++++++++++++ docs-ai/027-split-pane-ux/001-action.md | 64 +++++++++ .../027-split-pane-ux/002-split-zoom-ux.md | 61 ++++++++ docs-ai/028-pr-status-tracking/000-plan.md | 101 +++++++++++++ docs-ai/028-pr-status-tracking/001-action.md | 118 +++++++++++++++ .../002-merge-queue-and-fork-remotes.md | 55 +++++++ ...003-status-fidelity-and-refresh-cadence.md | 54 +++++++ .../004-flicker-and-no-pr-semantics.md | 62 ++++++++ docs-ai/029-active-agents-panel/000-plan.md | 136 ++++++++++++++++++ docs-ai/029-active-agents-panel/001-action.md | 85 +++++++++++ .../002-selection-and-keyboard-navigation.md | 53 +++++++ .../003-row-display-resolution.md | 45 ++++++ .../004-agent-busy-running-indicator.md | 50 +++++++ .../030-agent-status-detection/000-plan.md | 115 +++++++++++++++ .../030-agent-status-detection/001-action.md | 88 ++++++++++++ .../002-stability-and-scheduling.md | 52 +++++++ .../003-plain-command-running-indicator.md | 53 +++++++ .../000-plan.md | 107 ++++++++++++++ .../001-action.md | 95 ++++++++++++ .../002-post-buildout-fixes.md | 39 +++++ docs-ai/032-performance-hardening/000-plan.md | 96 +++++++++++++ .../032-performance-hardening/001-action.md | 58 ++++++++ .../002-june-upstream-ports.md | 43 ++++++ docs-ai/033-ui-refresh-2026-05/000-plan.md | 107 ++++++++++++++ docs-ai/033-ui-refresh-2026-05/001-action.md | 89 ++++++++++++ .../002-toolbar-icon-fixes.md | 34 +++++ .../000-plan.md | 111 ++++++++++++++ .../001-action.md | 79 ++++++++++ .../002-symlinked-roots.md | 58 ++++++++ .../003-duplicate-watcher-crash.md | 38 +++++ .../035-protected-terminal-close/000-plan.md | 86 +++++++++++ .../001-action.md | 60 ++++++++ .../000-plan.md | 103 +++++++++++++ .../001-action.md | 71 +++++++++ .../002-fullscreen-auxiliary-windows.md | 29 ++++ .../003-fullscreen-close-black-screen.md | 32 +++++ docs-ai/037-line-diff-tracking/000-plan.md | 91 ++++++++++++ docs-ai/037-line-diff-tracking/001-action.md | 78 ++++++++++ ...2-adaptive-debounce-and-untracked-lines.md | 58 ++++++++ .../003-deferred-refresh-after-commit.md | 42 ++++++ docs-ai/038-docs-agent-manual/000-plan.md | 101 +++++++++++++ docs-ai/038-docs-agent-manual/001-action.md | 57 ++++++++ .../002-docs-ai-follow-on.md | 36 +++++ docs-ai/039-gh-cli-hardening/000-plan.md | 96 +++++++++++++ docs-ai/039-gh-cli-hardening/001-action.md | 54 +++++++ .../002-login-shell-fallback-inversion.md | 33 +++++ .../003-upstream-hardening-batch.md | 39 +++++ docs-ai/040-automatic-open-in/000-plan.md | 112 +++++++++++++++ docs-ai/040-automatic-open-in/001-action.md | 72 ++++++++++ .../002-upstream-editor-ports.md | 50 +++++++ .../000-plan.md | 100 +++++++++++++ .../001-action.md | 63 ++++++++ docs-ai/042-project-workspaces/000-plan.md | 108 ++++++++++++++ docs-ai/042-project-workspaces/001-action.md | 65 +++++++++ .../002-sidebar-and-toolbar-follow-ups.md | 39 +++++ docs-ai/043-canvas-tile-layout/000-plan.md | 115 +++++++++++++++ docs-ai/043-canvas-tile-layout/001-action.md | 68 +++++++++ .../002-spacing-and-fit-margin-tweaks.md | 30 ++++ .../000-plan.md | 97 +++++++++++++ .../001-action.md | 70 +++++++++ .../000-plan.md | 127 ++++++++++++++++ .../001-action.md | 82 +++++++++++ docs-ai/README.md | 95 ++++++++++++ docs-ai/backfill-open-questions.md | 92 ++++++++++++ 164 files changed, 11589 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/write-ai-doc/SKILL.md create mode 100644 docs-ai/001-fork-bootstrap-and-release-pipeline/000-plan.md create mode 100644 docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md create mode 100644 docs-ai/001-fork-bootstrap-and-release-pipeline/002-homebrew-cask-automation.md create mode 100644 docs-ai/001-fork-bootstrap-and-release-pipeline/003-appcast-from-github-releases.md create mode 100644 docs-ai/002-custom-commands/000-plan.md create mode 100644 docs-ai/002-custom-commands/001-action.md create mode 100644 docs-ai/002-custom-commands/002-ui-revamp-and-keybinding-unification.md create mode 100644 docs-ai/002-custom-commands/003-split-target-and-close-on-success.md create mode 100644 docs-ai/003-diff-window/000-plan.md create mode 100644 docs-ai/003-diff-window/001-action.md create mode 100644 docs-ai/003-diff-window/002-external-diff-tools.md create mode 100644 docs-ai/003-diff-window/003-render-pipeline-hardening.md create mode 100644 docs-ai/003-diff-window/004-appearance-follows-app.md create mode 100644 docs-ai/004-prowl-rebrand/000-plan.md create mode 100644 docs-ai/004-prowl-rebrand/001-action.md create mode 100644 docs-ai/004-prowl-rebrand/002-migration-copy-not-move.md create mode 100644 docs-ai/005-canvas-live-sessions/000-plan.md create mode 100644 docs-ai/005-canvas-live-sessions/001-action.md create mode 100644 docs-ai/005-canvas-live-sessions/002-cmd-w-close-semantics.md create mode 100644 docs-ai/006-startup-performance/000-plan.md create mode 100644 docs-ai/006-startup-performance/001-action.md create mode 100644 docs-ai/006-startup-performance/002-upstream-contribution.md create mode 100644 docs-ai/007-ghostty-embedding-integration/000-plan.md create mode 100644 docs-ai/007-ghostty-embedding-integration/001-action.md create mode 100644 docs-ai/007-ghostty-embedding-integration/002-theme-appearance-sync.md create mode 100644 docs-ai/007-ghostty-embedding-integration/003-text-and-key-event-safety.md create mode 100644 docs-ai/008-terminal-notifications/000-plan.md create mode 100644 docs-ai/008-terminal-notifications/001-action.md create mode 100644 docs-ai/008-terminal-notifications/002-notification-jump-and-indicators.md create mode 100644 docs-ai/008-terminal-notifications/003-toolbar-dock-options-and-stuck-indicator.md create mode 100644 docs-ai/008-terminal-notifications/004-sound-picker-and-viewed-surface-mute.md create mode 100644 docs-ai/009-terminal-surface-lifecycle/000-plan.md create mode 100644 docs-ai/009-terminal-surface-lifecycle/001-action.md create mode 100644 docs-ai/009-terminal-surface-lifecycle/002-surface-resource-waste.md create mode 100644 docs-ai/010-plain-folder-support/000-plan.md create mode 100644 docs-ai/010-plain-folder-support/001-action.md create mode 100644 docs-ai/010-plain-folder-support/002-plain-upgrade-watchers.md create mode 100644 docs-ai/011-canvas-multiselect-broadcast/000-plan.md create mode 100644 docs-ai/011-canvas-multiselect-broadcast/001-action.md create mode 100644 docs-ai/012-keybinding-system/000-plan.md create mode 100644 docs-ai/012-keybinding-system/001-action.md create mode 100644 docs-ai/012-keybinding-system/002-ghostty-key-equivalent-ownership.md create mode 100644 docs-ai/012-keybinding-system/003-chained-binding-hint-limitation.md create mode 100644 docs-ai/013-prowl-cli/000-plan.md create mode 100644 docs-ai/013-prowl-cli/001-action.md create mode 100644 docs-ai/013-prowl-cli/002-agents-command.md create mode 100644 docs-ai/014-terminal-layout-persistence/000-plan.md create mode 100644 docs-ai/014-terminal-layout-persistence/001-action.md create mode 100644 docs-ai/014-terminal-layout-persistence/002-launch-restore-races.md create mode 100644 docs-ai/015-repositories-feature-refactor/000-plan.md create mode 100644 docs-ai/015-repositories-feature-refactor/001-action.md create mode 100644 docs-ai/015-repositories-feature-refactor/002-split-large-swift-files.md create mode 100644 docs-ai/015-repositories-feature-refactor/003-worktree-stream-request-api.md create mode 100644 docs-ai/016-dev-build-and-ci-workflow/000-plan.md create mode 100644 docs-ai/016-dev-build-and-ci-workflow/001-action.md create mode 100644 docs-ai/016-dev-build-and-ci-workflow/002-format-lint-alignment.md create mode 100644 docs-ai/016-dev-build-and-ci-workflow/003-ci-throughput-and-caching.md create mode 100644 docs-ai/016-dev-build-and-ci-workflow/004-debug-identity-and-dev-loop.md create mode 100644 docs-ai/017-upstream-sync-process/000-plan.md create mode 100644 docs-ai/017-upstream-sync-process/001-action.md create mode 100644 docs-ai/017-upstream-sync-process/002-plan-doc-batch-workflow.md create mode 100644 docs-ai/018-archived-worktrees/000-plan.md create mode 100644 docs-ai/018-archived-worktrees/001-action.md create mode 100644 docs-ai/018-archived-worktrees/002-archived-button-toggle.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/000-plan.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/001-action.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/002-worktree-history-navigation.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/003-safe-branch-deletion-and-cleanup.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/004-advanced-placement-overrides.md create mode 100644 docs-ai/019-worktree-creation-and-lifecycle/005-add-to-prowl-clone.md create mode 100644 docs-ai/020-observability/000-plan.md create mode 100644 docs-ai/020-observability/001-action.md create mode 100644 docs-ai/020-observability/002-app-hang-removal.md create mode 100644 docs-ai/020-observability/003-identity-and-network-noise.md create mode 100644 docs-ai/021-sparkle-update-ux/000-plan.md create mode 100644 docs-ai/021-sparkle-update-ux/001-action.md create mode 100644 docs-ai/021-sparkle-update-ux/002-sparkle-292-and-driver-isolation.md create mode 100644 docs-ai/021-sparkle-update-ux/003-background-update-downloads.md create mode 100644 docs-ai/021-sparkle-update-ux/004-install-confirmation.md create mode 100644 docs-ai/022-tab-title-and-icon/000-plan.md create mode 100644 docs-ai/022-tab-title-and-icon/001-action.md create mode 100644 docs-ai/022-tab-title-and-icon/002-auto-detected-icons.md create mode 100644 docs-ai/022-tab-title-and-icon/003-persistent-custom-titles.md create mode 100644 docs-ai/023-shelf-mode/000-plan.md create mode 100644 docs-ai/023-shelf-mode/001-action.md create mode 100644 docs-ai/023-shelf-mode/002-book-switch-jank.md create mode 100644 docs-ai/023-shelf-mode/003-agent-status-and-trackpad.md create mode 100644 docs-ai/024-canvas-interaction-evolution/000-plan.md create mode 100644 docs-ai/024-canvas-interaction-evolution/001-action.md create mode 100644 docs-ai/024-canvas-interaction-evolution/002-first-class-canvas.md create mode 100644 docs-ai/024-canvas-interaction-evolution/003-keyboard-and-layout-wave.md create mode 100644 docs-ai/024-canvas-interaction-evolution/004-completeness-wave.md create mode 100644 docs-ai/025-repo-identity-appearance/000-plan.md create mode 100644 docs-ai/025-repo-identity-appearance/001-action.md create mode 100644 docs-ai/025-repo-identity-appearance/002-upstream-divergence.md create mode 100644 docs-ai/026-sidebar-container-refactor/000-plan.md create mode 100644 docs-ai/026-sidebar-container-refactor/001-action.md create mode 100644 docs-ai/026-sidebar-container-refactor/002-add-repository-entry-point.md create mode 100644 docs-ai/027-split-pane-ux/000-plan.md create mode 100644 docs-ai/027-split-pane-ux/001-action.md create mode 100644 docs-ai/027-split-pane-ux/002-split-zoom-ux.md create mode 100644 docs-ai/028-pr-status-tracking/000-plan.md create mode 100644 docs-ai/028-pr-status-tracking/001-action.md create mode 100644 docs-ai/028-pr-status-tracking/002-merge-queue-and-fork-remotes.md create mode 100644 docs-ai/028-pr-status-tracking/003-status-fidelity-and-refresh-cadence.md create mode 100644 docs-ai/028-pr-status-tracking/004-flicker-and-no-pr-semantics.md create mode 100644 docs-ai/029-active-agents-panel/000-plan.md create mode 100644 docs-ai/029-active-agents-panel/001-action.md create mode 100644 docs-ai/029-active-agents-panel/002-selection-and-keyboard-navigation.md create mode 100644 docs-ai/029-active-agents-panel/003-row-display-resolution.md create mode 100644 docs-ai/029-active-agents-panel/004-agent-busy-running-indicator.md create mode 100644 docs-ai/030-agent-status-detection/000-plan.md create mode 100644 docs-ai/030-agent-status-detection/001-action.md create mode 100644 docs-ai/030-agent-status-detection/002-stability-and-scheduling.md create mode 100644 docs-ai/030-agent-status-detection/003-plain-command-running-indicator.md create mode 100644 docs-ai/031-command-palette-architecture/000-plan.md create mode 100644 docs-ai/031-command-palette-architecture/001-action.md create mode 100644 docs-ai/031-command-palette-architecture/002-post-buildout-fixes.md create mode 100644 docs-ai/032-performance-hardening/000-plan.md create mode 100644 docs-ai/032-performance-hardening/001-action.md create mode 100644 docs-ai/032-performance-hardening/002-june-upstream-ports.md create mode 100644 docs-ai/033-ui-refresh-2026-05/000-plan.md create mode 100644 docs-ai/033-ui-refresh-2026-05/001-action.md create mode 100644 docs-ai/033-ui-refresh-2026-05/002-toolbar-icon-fixes.md create mode 100644 docs-ai/034-worktree-watcher-correctness/000-plan.md create mode 100644 docs-ai/034-worktree-watcher-correctness/001-action.md create mode 100644 docs-ai/034-worktree-watcher-correctness/002-symlinked-roots.md create mode 100644 docs-ai/034-worktree-watcher-correctness/003-duplicate-watcher-crash.md create mode 100644 docs-ai/035-protected-terminal-close/000-plan.md create mode 100644 docs-ai/035-protected-terminal-close/001-action.md create mode 100644 docs-ai/036-window-management-hardening/000-plan.md create mode 100644 docs-ai/036-window-management-hardening/001-action.md create mode 100644 docs-ai/036-window-management-hardening/002-fullscreen-auxiliary-windows.md create mode 100644 docs-ai/036-window-management-hardening/003-fullscreen-close-black-screen.md create mode 100644 docs-ai/037-line-diff-tracking/000-plan.md create mode 100644 docs-ai/037-line-diff-tracking/001-action.md create mode 100644 docs-ai/037-line-diff-tracking/002-adaptive-debounce-and-untracked-lines.md create mode 100644 docs-ai/037-line-diff-tracking/003-deferred-refresh-after-commit.md create mode 100644 docs-ai/038-docs-agent-manual/000-plan.md create mode 100644 docs-ai/038-docs-agent-manual/001-action.md create mode 100644 docs-ai/038-docs-agent-manual/002-docs-ai-follow-on.md create mode 100644 docs-ai/039-gh-cli-hardening/000-plan.md create mode 100644 docs-ai/039-gh-cli-hardening/001-action.md create mode 100644 docs-ai/039-gh-cli-hardening/002-login-shell-fallback-inversion.md create mode 100644 docs-ai/039-gh-cli-hardening/003-upstream-hardening-batch.md create mode 100644 docs-ai/040-automatic-open-in/000-plan.md create mode 100644 docs-ai/040-automatic-open-in/001-action.md create mode 100644 docs-ai/040-automatic-open-in/002-upstream-editor-ports.md create mode 100644 docs-ai/041-ghosttykit-prebuilt-artifacts/000-plan.md create mode 100644 docs-ai/041-ghosttykit-prebuilt-artifacts/001-action.md create mode 100644 docs-ai/042-project-workspaces/000-plan.md create mode 100644 docs-ai/042-project-workspaces/001-action.md create mode 100644 docs-ai/042-project-workspaces/002-sidebar-and-toolbar-follow-ups.md create mode 100644 docs-ai/043-canvas-tile-layout/000-plan.md create mode 100644 docs-ai/043-canvas-tile-layout/001-action.md create mode 100644 docs-ai/043-canvas-tile-layout/002-spacing-and-fit-margin-tweaks.md create mode 100644 docs-ai/044-foundation-model-branch-names/000-plan.md create mode 100644 docs-ai/044-foundation-model-branch-names/001-action.md create mode 100644 docs-ai/045-native-agent-session-detection/000-plan.md create mode 100644 docs-ai/045-native-agent-session-detection/001-action.md create mode 100644 docs-ai/README.md create mode 100644 docs-ai/backfill-open-questions.md diff --git a/.claude/skills/write-ai-doc/SKILL.md b/.claude/skills/write-ai-doc/SKILL.md new file mode 100644 index 00000000..ad5855af --- /dev/null +++ b/.claude/skills/write-ai-doc/SKILL.md @@ -0,0 +1,133 @@ +--- +name: write-ai-doc +description: Create and maintain spec-driven work records under docs-ai/ (numbered entries with 000-plan.md before implementation and 001-action.md after). Use when starting a medium/large feature, a complex or decision-shaping fix, or a non-trivial investigation — write the plan entry BEFORE coding; use also when amending an existing entry after follow-up work on the same topic. +--- + +# Write AI Doc + +`docs-ai/` is Prowl's durable record of how the app evolved: one numbered folder per +feature or decision-shaping fix, each holding an RFC-like plan and an action log. Future +humans and agents use it to answer "why is it built this way?" — so entries must stay +accurate against the code. Read `docs-ai/README.md` for the index and intent. + +## When to write one + +Create a new entry when the work is any of: + +- a feature that needs planning (multiple files/reducers, new UI surface, new subsystem); +- a fix whose investigation or decision matters later (root-cause hunts, perf hunts, + behavior-defining choices, upstream-divergence decisions); +- an investigation worth keeping even if no code changes. + +Skip it for: trivial/small fixes, pure formatting or dependency bumps, routine upstream +ports already recorded in the upstream ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`), and docs-only changes. When in +doubt, a short entry beats a missing one. + +## Workflow + +### 1. New entry — plan first, before coding + +1. Pick the next number: `ls docs-ai/ | sort` and take highest `NNN` + 1 (three digits). +2. Create `docs-ai/NNN-/000-plan.md` from the template below, `Status: Planned`. + Write it as part of planning — background, goals, approach, alternatives — not as an + afterthought. +3. Implement the work (normal branch/PR flow). +4. Write `001-action.md`: what actually happened, chronological, with PR/commit refs, the + resulting key files, and deviations from the plan. Flip plan status to `Implemented`. + Ship the docs in the same PR as the change when practical. + +### 2. Follow-up on an existing entry (in-frame fix or extension) + +1. Add the next-numbered file in the folder, e.g. `002-.md` (template below). +2. At the end of `000-plan.md`'s **Amendments** section append: + `- Updated 2026-MM-DD: — see [002-.md](002-.md)`. +3. If the follow-up invalidates part of the plan or action text, correct that text in + place (keep it truthful) and note the correction in the amendment. + +### 3. Large pivot / redesign + +If the change replaces the entry's approach rather than patching it, open a NEW numbered +entry, cross-link both directions, and mark the old plan `Status: Superseded by +[NNN-new-slug](../NNN-new-slug/000-plan.md)`. + +## Templates + +### 000-plan.md + +```markdown +# NNN — : Plan + +| | | +| --- | --- | +| **Status** | Planned \| Implemented \| Superseded by <link> | +| **Anchor date** | 2026-MM-DD | +| **Primary PRs** | #a, #b (fill in as they merge) | +| **Related** | [NNN-other](../NNN-other/000-plan.md), `docs/...` | + +## Background +The problem/pain and its context; for investigations, the observed symptom. + +## Goals +Bullets. Add a **Non-goals** subsection when scope exclusion is a real decision. + +## Design / Approach +The intended approach; name the key types/files it touches. + +## Alternatives & decisions +Options considered and why the chosen one won. Record decisions, not just designs. + +## Amendments +(append `- Updated 2026-MM-DD: ... — see [00N-topic.md](00N-topic.md)` lines here) +``` + +### 001-action.md + +```markdown +# NNN — <Title>: Action Log + +## Timeline +| Date | Change | Ref | +| --- | --- | --- | + +## Outcome & current state (as of 2026-MM-DD) +What exists in code now; key files/types with repo-relative paths. + +## Deviations from plan +Where reality diverged from 000-plan.md, or "None known." + +## Open questions +Unverified claims, oddities worth revisiting, or "None." +``` + +### Amendment (002+) + +```markdown +# NNN.00M — <Topic> + +## Context +Why this follow-up happened. + +## Change +What was done. | ## Refs: PR #x | ## Current state (optional) +``` + +## Writing rules + +- English, factual, RFC-ish; prefer tables over prose for timelines. Plans are typically + 40–120 lines, actions 30–100 — long enough to be useful, short enough to be read. +- Every repo-relative file path you write must exist (verify with Glob/Grep before + writing). Facts you can't verify belong under **Open questions**, not in prose. +- Reference fork PRs as `#123`, upstream PRs as `upstream #123`, files as inline code. + Cross-link sibling entries with relative links. +- `docs-ai/` is the single home for fork history AND fork-internal operational docs. + Numbered files are immutable history; **non-numbered** files inside an entry folder + (e.g. `001-.../release-runbook.md`, `017-.../upstream-ledger.md`, + `013-prowl-cli/contracts/`, `020-observability/runbook.md`) are living documents — + update them in place when the process/contract they describe changes, and link them + instead of duplicating their content. +- `docs/` (the user-facing agent manual) is separate: current behavior goes there, + history/decisions/runbooks go in docs-ai. Never link `doc-onevcat/` — that directory + was dissolved into docs-ai in 2026-07. +- The Xcode module/scheme is still `supacode`; `supacode/...` paths are correct. +- After adding or renaming an entry, add/refresh its row in `docs-ai/README.md`'s index. +- Do not state build/test results you didn't produce. diff --git a/AGENTS.md b/AGENTS.md index 002a2a32..09fc452a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -This fork is primarily for onevcat-specific customizations; before doing any release work, read `doc-onevcat/fork-sync-and-release.md` (and `doc-onevcat/change-list.md`) for fork publishing guidance. +This fork is primarily for onevcat-specific customizations; before doing any release work, read `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` (and the upstream ledger `docs-ai/017-upstream-sync-process/upstream-ledger.md`) for fork publishing guidance. Fork history and design decisions are recorded under `docs-ai/` (see `docs-ai/README.md`). ## Build Commands @@ -127,6 +127,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream<Event> - After a task, ensure the app builds: `make build-app` - When working on CLI code (`ProwlCLI/`, `ProwlCLITests/`, `Package.swift`), run `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` before committing. - When you change user-facing behavior (keyboard shortcuts, settings, the `prowl` CLI, or a feature's UX), update the matching file under `docs/` in the same change. For a full audit, run the `sync-docs` skill. +- When starting a medium/large feature, a complex or decision-shaping fix, or a non-trivial investigation, use the `write-ai-doc` skill: create a `docs-ai/NNN-<slug>/000-plan.md` before coding and complete `001-action.md` after implementation. Follow-up work on the same topic amends the existing entry (see `docs-ai/README.md`). - When implementing a new feature or fixing a bug that is unrelated to the current branch's active work, first create a dedicated branch from the latest `origin/main`; then work, commit, push, and open a PR from that branch. - Automatically commit your changes and your changes only. Do not use `git add .` - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/000-plan.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/000-plan.md new file mode 100644 index 00000000..a574c66e --- /dev/null +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/000-plan.md @@ -0,0 +1,115 @@ +# 001 — Fork Bootstrap and Release Pipeline: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-02-26 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #73, #74, #75, #78, #154 (early work landed as direct commits: `3599f5f7`, `058177e5`, `a66c4b20`, `56deb491`, `2ab70fd7`, `64829dc0`, `1b0eb02b`, `64d09282`, `849b5cf2`, `7f79078f`, `4546b66c`) | +| **Sources** | `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`, `docs-ai/017-upstream-sync-process/upstream-ledger.md` (Old Log), PR descriptions #73/#74/#75/#78/#154, commit messages | +| **Related** | `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` (living runbook), `.claude/skills/release/SKILL.md`, `.claude/skills/check-upstream-changes/` | + +## Background + +`onevcat/Prowl` is a personal fork of `supabitapp/supacode`, rebranded to Prowl and +carrying fork-only features. Upstream ships releases through its own GitHub Actions +workflows (`release.yml`, `release-tip.yml`) with semantic versions, upstream signing +identities, and a `tip` prerelease channel — none of which the fork can or should use: +the fork has its own Developer ID, its own Sparkle update feed, and no CI secrets for +signing. Without dedicated infrastructure the fork could not publish installable, +auto-updating builds at all, and keeping up with upstream required an ad-hoc merge +process. + +This entry covers everything that made the fork independently releasable: the upstream +sync workflow, the local notarized release pipeline, Sparkle auto-update, date-based +versioning, Claude-generated release notes, neutralizing upstream CI release machinery, +and the agent-facing slash commands/skills that drive it. + +## Goals + +- Repeatable upstream sync: `upstream/main` merged into origin `main` with a + deterministic, scripted flow (`rerere` for recurring conflicts). +- A fully local release pipeline: archive, Developer ID signing, DMG, notarization + + stapling, publish to GitHub Releases — runnable from the dev machine by an agent. +- Notarized-only policy: no code path may publish a non-notarized build. +- Sparkle auto-update owned by the fork: EdDSA key pair, `SUFeedURL`, appcast generated + and published per release. +- Date-based versioning `YYYY.M.DD` (same-day suffix `.N`, build number `YYYYMMDD`), + decoupled from upstream's semver tags. +- User-facing release notes generated by Claude from commits/PRs, human-reviewed before + publishing. +- Remove upstream CI release workflows and make the `tip` update channel equivalent to + `stable`, without deleting upstream types (to keep future merges cheap). + +**Non-goals** + +- Reusing or adapting upstream's GitHub Actions release workflows (no signing secrets in + CI; releases stay local by design). +- A separate prerelease/`tip` distribution channel for the fork. + +## Design / Approach + +Two tracks, documented in the living runbook `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`: + +**Sync track** — two remotes (`origin`, `upstream`); `main` is the integration branch. +`scripts/sync-upstream-main.sh` runs preflight checks (clean tree, remotes +and branches exist), fetches both remotes separately, `git merge --ff-only origin/main`, +then `git merge --no-ff upstream/main`, and verifies with `make build-app`. `git rerere` +is enabled so recurring conflicts resolve once. + +**Release track** — `scripts/release.sh` as a single pipeline: + +1. Version bump (date-based) + signed git tag via the `Makefile` `bump-version` target + (rewrites `MARKETING_VERSION`/`CURRENT_PROJECT_VERSION` in + `supacode.xcodeproj/project.pbxproj`). +2. Release archive (`make archive`), re-sign embedded Sparkle/Sentry frameworks with the + Developer ID identity. +3. DMG via `create-dmg`, plus a signed `Prowl.app.zip` for Sparkle deltas. +4. Notarization (`xcrun notarytool submit --keychain-profile`, retry loop) + stapling — + unconditional, no opt-out. +5. Appcast generation with the bundled `bins/generate_appcast` (Sparkle EdDSA private + key from `~/.prowl-sparkle-private-key`; public key in `supacode/Info.plist` as + `SUPublicEDKey`). +6. GitHub Release with `Prowl.dmg`, `Prowl.app.zip`, and the appcast. + +Release notes are generated by the `claude` CLI from the commit/PR range since the +previous tag, with GitHub auto-notes as fallback, and must be reviewed before the +publish step runs. Agent entry points: initially a `/fork-release` slash command, later +split into `/sync-upstream` (sync only) and `/release` (publish); today the release flow +lives in `.claude/skills/release/SKILL.md`. + +Upstream CI neutralization: delete `.github/workflows/release.yml` and +`release-tip.yml`; in `supacode/Clients/Updates/UpdaterClient.swift` make +`allowedChannels(for:)` return the empty set so `tip` behaves exactly like `stable`, +while keeping the `UpdateChannel` enum (`supacode/Features/Settings/Models/UpdateChannel.swift`) +intact. + +## Alternatives & decisions + +- **Private-first, public later.** The first iteration (`release-to-fork.sh`, + 2026-02-26) published personal builds only; the public pipeline with Sparkle/DMG + (`release.sh`, 2026-03-18) superseded it. The old script is kept with a `DEPRECATED` + header for reference. +- **Notarized-only, no escape hatch** (`2ab70fd7`, 2026-02-27). The original script had + `ENABLE_NOTARIZATION=0` for the old behavior; that path was turned into a hard error + and the rule promoted to `CLAUDE.md` ("Fork releases must be notarized"). +- **Local releases instead of CI** (`7f79078f`, 2026-03-23). Upstream's release + workflows were removed rather than adapted — build/sign/notarize/publish all happen on + the dev machine via the `/release` skill. A precursor commit (`85b3fd7`, see + change-list Old Log) had already disabled the push-triggered `tip` workflow. +- **Neutralize `tip` instead of deleting it** (`4546b66c`, 2026-03-23). The enum case is + kept and `allowedChannels` returns `[]` specifically to avoid upstream merge conflicts. +- **Appcast host.** Initially served from the Prowl website + (`https://prowl.onev.cat/appcast.xml`); switched to a GitHub Release asset in #154 — + see [003-appcast-from-github-releases.md](003-appcast-from-github-releases.md). +- **Release notes: generate, but never overwrite a human.** Claude generation + (`64d09282`) skips itself when pre-written notes exist (`849b5cf2`); later split into + a standalone `release-notes.sh` with a mandatory review gate before `release.sh` runs + (`1a7c2e77`, 2026-03-25). + +## Amendments + +- Updated 2026-03-26: Homebrew cask automation on `release.published` — see + [002-homebrew-cask-automation.md](002-homebrew-cask-automation.md) +- Updated 2026-04-05: Sparkle appcast served from GitHub Releases instead of Prowl-Site — + see [003-appcast-from-github-releases.md](003-appcast-from-github-releases.md) diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md new file mode 100644 index 00000000..165cbd70 --- /dev/null +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md @@ -0,0 +1,84 @@ +# 001 — Fork Bootstrap and Release Pipeline: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-02-26 | Fork sync + personal release workflow docs and helper scripts (`fork-sync-and-release.md`, `sync-upstream-main.sh`, `release-to-fork.sh`) | `3599f5f7` | +| 2026-02-26 | Release script hardening: detect target repo from `origin`, `gh api` + upload fallback when `gh release create` fails | `058177e5` | +| 2026-02-26 | Local notarization flow: Developer ID signing, `notarytool` submit, stapling | `a66c4b20` | +| 2026-02-27 | Deterministic upstream sync: separate fetches, `--ff-only` from origin then `--no-ff` from upstream, preflight checks | `56deb491` | +| 2026-02-27 | Notarized-only policy: script errors out on `ENABLE_NOTARIZATION!=1`; rule added to `CLAUDE.md` | `2ab70fd7` | +| 2026-03-05 | `/fork-release` slash command (upstream sync + private release) | `64829dc0` | +| 2026-03-18 | Public release infrastructure: Sparkle EdDSA key + feed URL in `Info.plist`, date-based `YYYY.M.DD` versioning, `release.sh` full pipeline (archive → re-sign → DMG → notarize → appcast → GitHub Release), `install-release` Makefile target; `/fork-release` split into `/sync-upstream` + `/release`; `release-to-fork.sh` deprecated | `1b0eb02b` | +| 2026-03-19 | Claude-generated user-facing release notes in the release script | `64d09282` | +| 2026-03-22 | Skip note generation when pre-written notes exist | `849b5cf2` | +| 2026-03-23 | Removed CI release workflows (`release.yml`, `release-tip.yml`, −604 lines) | `7f79078f` | +| 2026-03-23 | `tip` update channel made equivalent to `stable` (empty `allowedChannels`, enum kept) | `4546b66c` | +| 2026-03-25 | Notes generation split into standalone `release-notes.sh` with a review gate before `release.sh` | `1a7c2e77` | +| 2026-03-26 | Homebrew cask automation from published releases | #73, #74, #75 — see [002](002-homebrew-cask-automation.md) | +| 2026-03-27 | Hardened `install-dev-build`/`install-release`: `set -euo pipefail`, strict destination guards, `trash` instead of `rm -rf` | #78 | +| 2026-04-05 | Sparkle appcast served from GitHub Releases; Prowl-Site appcast push removed | #154 — see [003](003-appcast-from-github-releases.md) | + +## Outcome & current state (as of 2026-07-12) + +- `scripts/release.sh` — the active pipeline. Requires a reviewed + `build/release-notes.md` up front; notarization is unconditional (3-attempt + `notarytool` retry, then staple) with no skip flag; appcast is generated by + `bins/generate_appcast` after seeding history from the latest GitHub Release; uploads + `Prowl.dmg`, `Prowl.app.zip`, `appcast.xml`. Later extensions beyond this entry's + frame: Sentry dSYM upload/release tracking (#210) and an optional + `NETLIFY_BUILD_HOOK` Prowl-Site rebuild trigger. +- `scripts/release-notes.sh` — gathers commits + PR descriptions since the + previous tag and generates notes via the `claude` CLI, falling back to GitHub + auto-notes; enforces `### New/Fixed/Improved` headings. +- `scripts/sync-upstream-main.sh` — sync automation as designed. + `release-to-fork.sh` (deprecated legacy pipeline) was removed in the 2026-07 docs-ai migration; see git history. +- `supacode/Info.plist` — `SUFeedURL` = + `https://github.com/onevcat/Prowl/releases/latest/download/appcast.xml`, + `SUPublicEDKey` set. +- `Makefile` — `bump-version` (date-based version validation, signed tag, also writes + `supacode/CLIService/Shared/ProwlVersion.swift`), `archive`, `install-release`, and + the #78-hardened `install-dev-build`. +- `.claude/skills/release/SKILL.md` — the agent flow: branch/clean-tree checks, + `sync-docs` before bump+tag (#411), note generation + explicit user confirmation, then + `release.sh`. +- `.github/workflows/` — only `test.yml` and `release-homebrew-cask.yml` remain; the + upstream release workflows are gone. +- `supacode/Clients/Updates/UpdaterClient.swift` — `allowedChannels(for:)` returns `[]` + ("Tip channel is no longer published separately"); `UpdateChannel` in + `supacode/Features/Settings/Models/UpdateChannel.swift` still declares `case tip`. +- The `/sync-upstream` command no longer exists: it was removed in the commands→skills + migration (`01d4e04f`, 2026-04-08). Upstream review is handled by the + `.claude/skills/check-upstream-changes` skill; the merge runbook stays in + `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`. +- The pipeline's shape later constrained other decisions: the 2026-04-20 upstream review + (`docs-ai/017-upstream-sync-process/upstream-ledger.md`) skipped upstream's Tuist migration partly because it + would force rewriting the `/release` skill, notarization flow, and appcast generation. + +## Deviations from plan + +- Appcast hosting moved from Prowl-Site to GitHub Releases + ([003](003-appcast-from-github-releases.md)); the original design published the feed + at `https://prowl.onev.cat/appcast.xml`. +- Release-note generation moved out of `release.sh` into a separate script with a + mandatory human review step, rather than running inline during publish. +- `/sync-upstream` was dropped as a command; sync is now the documented runbook plus + `sync-upstream-main.sh`, with `check-upstream-changes` covering upstream review. + +## Open questions + +- `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` still says "use the `/sync-upstream` command", + but that command was removed in `01d4e04f` (2026-04-08). The living doc looks stale on + this point (left untouched by this backfill). +- The `Makefile` `bump-and-release` target (inherited from upstream, `e30b747b`) pushes + a tag and creates a GitHub Release with generated notes but no notarized artifacts — + it bypasses `release.sh` entirely, conflicts with the notarized-only policy, and its + `release.published` event would trigger `release-homebrew-cask.yml` against a + `Prowl.dmg` asset that does not exist. `CLAUDE.md` still lists it as "Bump version and + push to trigger release" even though push-triggered release workflows were removed in + `7f79078f`. +- The `ENABLE_NOTARIZATION` guard from `2ab70fd7` lived only in the now-removed deprecated + `release-to-fork.sh`; the active `release.sh` enforces notarization structurally (no + flag at all). The `CLAUDE.md` rule still phrases the policy in terms of + `ENABLE_NOTARIZATION=0`, which no active script reads. diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/002-homebrew-cask-automation.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/002-homebrew-cask-automation.md new file mode 100644 index 00000000..51add0c4 --- /dev/null +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/002-homebrew-cask-automation.md @@ -0,0 +1,39 @@ +# 001.002 — Homebrew Cask Automation + +## Context + +Prowl is distributed through the personal tap `onevcat/homebrew-tap` as well as direct +DMG download. Releases are built and published locally +([000-plan.md](000-plan.md)), so nothing was keeping the tap's cask +(`Casks/prowl.rb`) in sync with new versions. Signing/notarization must stay local, but +cask metadata (`version` + `sha256`) is safe to automate in CI. + +## Change + +Added `.github/workflows/release-homebrew-cask.yml` (#73): + +- Triggers on `release.published`, with a `workflow_dispatch` fallback that takes a tag + input for manual re-runs. +- Downloads the just-published `Prowl.dmg` and computes its sha256. +- Updates (or creates) `Casks/prowl.rb` in `onevcat/homebrew-tap` and opens/updates a PR + from branch `prowl-<version>`, authenticated via the `TAP_GITHUB_TOKEN` secret + (`homebrew-release` environment). +- The generated cask installs the app only; no CLI `binary` stanza. + +Two same-day fixes to keep the generated cask compliant with the tap's lint rules: + +- #74 — `desc` changed to "Coding agent orchestrator"; tap style forbids platform names + ("macOS") in `desc`, and existing casks are normalized on update. +- #75 — replaced broad `\s*` replacement regexes with line-scoped ones so blank lines + after stanzas survive, fixing recurring `Cask/StanzaGrouping` failures in Homebrew + `test-bot`. + +## Refs + +PRs #73, #74, #75 (all merged 2026-03-26). + +## Current state + +`.github/workflows/release-homebrew-cask.yml` is one of only two workflows in the repo +(alongside `test.yml`) and still follows this design: metadata-only sync, PR-based +updates to `onevcat/homebrew-tap`, app-only cask. diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/003-appcast-from-github-releases.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/003-appcast-from-github-releases.md new file mode 100644 index 00000000..76421aba --- /dev/null +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/003-appcast-from-github-releases.md @@ -0,0 +1,32 @@ +# 001.003 — Serve Sparkle Appcast from GitHub Releases + +## Context + +The 2026-03-18 release infrastructure (`1b0eb02b`) published the Sparkle feed at +`https://prowl.onev.cat/appcast.xml`, which meant every release also had to push +`appcast.xml` into the Prowl-Site repository and wait for a site deploy — an extra step +and an extra failure point on the critical update path. + +## Change + +- `SUFeedURL` in `supacode/Info.plist` now points at + `https://github.com/onevcat/Prowl/releases/latest/download/appcast.xml`, i.e. the + appcast is just another asset on the latest GitHub Release (GitHub 302-redirects + `releases/latest/download/...` to the newest release's asset). +- `release.sh` seeds appcast version history by downloading the previous `appcast.xml` + from the latest GitHub Release instead of from Prowl-Site, then regenerates it with + `bins/generate_appcast`. +- The Prowl-Site appcast push step was removed from the release script entirely. The + optional `NETLIFY_BUILD_HOOK` site-rebuild trigger remains, but only for the website + itself, not for update delivery. + +## Refs + +PR #154 (merged 2026-04-05). + +## Current state + +Matches the change: `supacode/Info.plist` carries the GitHub Releases `SUFeedURL`, and +`scripts/release.sh` fetches the prior appcast from +`releases/latest/download/appcast.xml` before generating and uploading the new one +alongside `Prowl.dmg` and `Prowl.app.zip`. diff --git a/docs-ai/002-custom-commands/000-plan.md b/docs-ai/002-custom-commands/000-plan.md new file mode 100644 index 00000000..6d01221a --- /dev/null +++ b/docs-ai/002-custom-commands/000-plan.md @@ -0,0 +1,76 @@ +# 002 — Custom Commands: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-02-27 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #101, #205 (initial wave was direct commits `76046bc0`, `b5c58e4d`, `562042fc`) | +| **Sources** | `docs-ai/017-upstream-sync-process/upstream-ledger.md` (Old Log rows for `76046bc`/`b5c58e4`/`562042f`), fork issue #85, PR descriptions #101/#205/#245/#299/#362, commit messages | +| **Related** | [012-keybinding-system](../012-keybinding-system/000-plan.md), [022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md), `docs/components/custom-actions.md` | + +## Background + +Upstream supacode offered exactly one on-demand per-repo command: the Run Script. onevcat +wanted repeated repo workflows (build, test, push, one-shot agent prompts) available as +first-class buttons and hotkeys next to Run — multiple named actions per repository, each +with its own icon and execution behavior. This was one of the first fork-only features +(day two of the fork), so it also had to be structured to survive continuous upstream +merges. + +## Goals + +- Multiple named commands per repository, each with SF Symbol icon, title, shell command, + and execution mode. +- Two execution modes at introduction: run in a **new terminal tab** (`shellScript`) or + type into the **focused pane** (`terminalInput`). +- Terminal-input commands must actually *execute*, not just paste: inject the text and a + real Return key press, so shells and TUIs (agents) both treat it as Enter. +- Optional per-command keyboard shortcut that, while a repo is selected, takes precedence + over Ghostty's key handling and app shortcuts. +- Surfaces: buttons in the worktree toolbar (after Run) and entries in the Worktrees menu. + +**Non-goals** (initially): no split target, no auto-close, no palette or Canvas +integration — all of these arrived in later waves (see Amendments and the action log). + +## Design / Approach + +- **Model**: `OnevcatCustomCommand` (title / `systemImage` / `command` / + `OnevcatCustomCommandExecution` / optional `OnevcatCustomShortcut`) inside + `OnevcatRepositorySettings`, capped at 3 commands (`maxCustomCommands`). All types + carried an `Onevcat` prefix and lived in fork-added files, deliberately isolating the + feature from upstream-owned code to keep merges clean. (The prefix was later renamed to + `User*` on 2026-03-27, PR #79 era; see + [012-keybinding-system](../012-keybinding-system/000-plan.md).) +- **Storage**: a repo-scoped JSON file separate from upstream's settings + (`supacode.onevcat.json` at the repo root at the time; moved to + `~/.prowl/repo/<repo-last-path>/` and renamed `prowl.onevcat.json` during the rebrand — + see [004-prowl-rebrand](../004-prowl-rebrand/000-plan.md)). +- **Execution**: `AppFeature` action dispatches through `TerminalClient` — + `createTabWithInput` for the new-tab mode, `insertText` for terminal-input mode. +- **Terminal-input Return injection**: `GhosttySurfaceView.submitLine()` synthesizes a + `\r` keyDown/keyUp `NSEvent` pair (keyCode 36) through the normal key path instead of + appending `\n` to the injected text (commit `562042fc`). +- **Shortcut precedence**: a process-global `OnevcatCustomShortcutRegistry` records the + active repo's custom shortcuts so `GhosttySurfaceView.performKeyEquivalent` can let a + matching key combo bypass Ghostty and reach the SwiftUI `.keyboardShortcut` handlers. + +## Alternatives & decisions + +- **Fork-isolation over upstream integration**: prefixed types, fork-added files, and a + separate per-repo settings file were chosen so the feature adds few edit points in + upstream-owned files. The old change-list per-commit table marks all three initial + commits "Fork only". +- **Synthesized Return key over `\n` in text** (commit `562042fc`): text injection alone + left the command sitting unexecuted at the prompt in some programs; a synthesized key + event goes through the same path as a physical Enter. +- **3-command cap at introduction**: kept the toolbar bounded; removed a month later once + an overflow menu existed (#101, see amendment 002). + +## Amendments + +- Updated 2026-03-31: UI revamp — table + detail editor, 3-command cap removed, toolbar + overflow menu, shortcut recording unified with the keybinding system (#101, fork issue + #85) — see [002-ui-revamp-and-keybinding-unification.md](002-ui-revamp-and-keybinding-unification.md) +- Updated 2026-04-17: New Split execution target + per-command Close on success (#205) — + see [003-split-target-and-close-on-success.md](003-split-target-and-close-on-success.md) diff --git a/docs-ai/002-custom-commands/001-action.md b/docs-ai/002-custom-commands/001-action.md new file mode 100644 index 00000000..b89bd08d --- /dev/null +++ b/docs-ai/002-custom-commands/001-action.md @@ -0,0 +1,80 @@ +# 002 — Custom Commands: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-02-27 | Initial feature: repo-scoped custom command buttons (icon/title/command/execution mode, up to 3 per repo) in worktree toolbar + Worktrees menu; per-command shortcut overrides routed past Ghostty via a shortcut registry; shortcut editor layout polish | commits `76046bc0`, `b5c58e4d` | +| 2026-02-27 | Terminal-input commands execute via synthesized Return key events (`submitLine()`, keyCode 36) instead of pasted text only | commit `562042fc` | +| 2026-03-27 | `Onevcat*` type prefix renamed to `User*` (`UserRepositorySettings`, `UserCustomShortcutRegistry`) during shortcut-conflict work | commits `3ef622c7`, `f3f62a4e` (PR #79, see [012](../012-keybinding-system/000-plan.md)) | +| 2026-03-31 | UI revamp: editable command table, 3-command cap removed, toolbar overflow menu, SF Symbol preset picker, shortcut recording with repo-local conflict handling (keybinding milestone M4, fork issue #85) — see [002-ui-revamp-and-keybinding-unification.md](002-ui-revamp-and-keybinding-unification.md) | PR #101 | +| 2026-04-17 | New Split execution target (per-command direction) + Close on success toggle; 800 ms auto-close delay; success toast — see [003-split-target-and-close-on-success.md](003-split-target-and-close-on-success.md) | PR #205 | +| 2026-04-27 | Custom command icons pinned over command auto-detection for the run's lifetime; the model's `"terminal"` placeholder treated as "unset" so untouched commands keep auto-detection | PR #245 (owned by [022](../022-tab-title-and-icon/000-plan.md)) | +| 2026-05-18 | Custom commands surfaced in the command palette (typed-query only, stable UUID-based item IDs, per-command recency) | PR #299 (owned by [031](../031-command-palette-architecture/000-plan.md)) | +| 2026-05-28 | Canvas custom actions: Run/Stop Script and Custom Commands routed through the focused Canvas card; toolbar cluster kept as a single `ToolbarItem` to avoid card-switch jumps (community #358 by vince-hz + refinements) | PR #362 (owned by [024](../024-canvas-interaction-evolution/000-plan.md)) | +| 2026-06-07 | Settings UI split into dedicated files (`RepositorySettingsCustomCommandsView.swift`, `RepositorySettingsSupportingViews.swift`) as part of the large-file refactor | PR #403 (owned by [015](../015-repositories-feature-refactor/000-plan.md)) | + +## Outcome & current state (as of 2026-07-12) + +- **Model** — `supacode/Features/Settings/Models/UserRepositorySettings.swift`: + `UserCustomCommand` (id, title, `systemImage`, command, execution, `splitDirection`, + `closeOnSuccess`, optional `shortcut`), `UserCustomCommandExecution` + (`.shellScript` "New Tab" / `.terminalInput` "In Place" / `.split`), + `UserCustomShortcut` + `UserCustomShortcutModifiers`. `init(from:)` uses + `decodeIfPresent` with defaults so pre-#205 settings files keep decoding. No command + count cap remains. +- **Storage** — `supacode/Support/SupacodePaths.swift`: `prowl.onevcat.json` under + `~/.prowl/repo/<repo-last-path>/`, with legacy fallbacks for `supacode.onevcat.json` + (both the pre-rename directory file and the original repo-root location). +- **Execution** — `supacode/Features/App/Reducer/AppFeature.swift` + (`.runCustomCommand(index:)`): dispatches `terminalClient.send(.createTabWithInput)` / + `.createSplitWithInput` / `.insertText` per mode; treats an empty or `"terminal"` + `systemImage` as no icon so tab auto-detection still applies. + `supacode/Clients/Terminal/TerminalClient.swift` carries `autoCloseOnSuccess` and + `customCommandIcon` on both create commands. +- **Terminal input** — `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`: + `focusAndRunCommand(_:)` inserts the text into the focused surface then calls + `GhosttySurfaceView.submitLine()` (synthesized `\r` keyDown/keyUp, keyCode 36) in + `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`. +- **Close on success / toast** — + `supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift`: + `handleCommandFinished` consumes `autoCloseSurfaceIds` one-shot, schedules the delayed + auto-close on exit 0, and fires `onCustomCommandSucceeded` (success toast callback wired + in `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`). +- **Toolbar** — `supacode/Features/Repositories/Views/WorktreeDetailView.swift` + + `WorktreeDetailToolbarViews.swift`: `UserCustomCommandToolbarButton` for the first + commands, `CustomCommandOverflowButton` popover for the rest; shortcut labels resolved + via `store.resolvedKeybindings.keyboardShortcut(for:)`. The Canvas toolbar path renders + the same cluster (`supacode/Commands/WorktreeCommands.swift` provides the menu items). +- **Shortcuts** — `supacode/App/KeybindingSchema.swift`: + `LegacyCustomCommandShortcutMigration.migrate(commands:)` converts each per-command + `UserCustomShortcut` into a keybinding override; `appResolverSchema(customCommands:)` + injects per-command schema entries (scope `.customCommand`). The precedence hook + survives as `supacode/Infrastructure/Ghostty/UserCustomShortcutRegistry.swift`, + consulted in `supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift` and fed + through `supacode/Clients/Shortcuts/CustomShortcutRegistryClient.swift`. +- **Palette** — `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift`: + `customCommandItems(_:)`, `CommandPaletteItemID.customCommand`, subtitle + "Custom command in this repo · …" including execution mode and split direction. +- **Settings UI** — + `supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift` (+ + `RepositorySettingsSupportingViews.swift`, `RepositorySettingsView.swift`). +- **Tests** — `supacodeTests/AppFeatureCustomCommandTests.swift`, + `RepositorySettingsFeatureTests.swift`, `UserRepositorySettingsKeyTests.swift`. +- **User docs** — `docs/components/custom-actions.md` describes behavior and hotkey + precedence. + +## Deviations from plan + +- The original `Onevcat*` naming and the standalone shortcut model did not survive as + designed: types were renamed to `User*` (2026-03-27), and the per-command `shortcut` + field is now primarily a legacy carrier migrated into the config-driven keybinding + system ([012](../012-keybinding-system/000-plan.md)) rather than the source of truth for + display/resolution. +- The 3-command cap was an explicit part of the original design and was removed in #101. +- "Close on success" as merged in #205 closed immediately on exit 0; an 800 ms delay was + added in the same PR branch (`5d2c2836`) so the final output stays briefly visible. + +## Open questions + +- None. diff --git a/docs-ai/002-custom-commands/002-ui-revamp-and-keybinding-unification.md b/docs-ai/002-custom-commands/002-ui-revamp-and-keybinding-unification.md new file mode 100644 index 00000000..a542a5ab --- /dev/null +++ b/docs-ai/002-custom-commands/002-ui-revamp-and-keybinding-unification.md @@ -0,0 +1,46 @@ +# 002 — Amendment: UI Revamp & Keybinding Unification (#101) + +## Context + +By late March 2026 the original three-command settings form had outgrown itself, and the +config-driven keybinding system ([012-keybinding-system](../012-keybinding-system/000-plan.md)) +had landed its resolver and recorder milestones (M1–M3). Fork issue #85 ("[M4] Unify +custom commands UI with shared recorder and conflict engine") scheduled custom commands as +the keybinding project's fourth milestone: stop hand-rolling shortcut entry and conflict +checks in the repo settings form, and lift the arbitrary command cap. + +## Change + +PR #101 (merged 2026-03-31, "Issue #85: revamp repo custom commands UI and remove +3-command cap"): + +- Replaced the repository custom commands settings with a table + detail editor UX + (iterated during the branch into an inline-editable, scrollable lazy stack). +- Removed the legacy 3-command cap (`maxCustomCommands`). +- Shortcut recording now uses the shared recorder with **repo-local conflict handling + only** (Replace / Cancel between the repo's own commands); app-level conflict warning + noise was removed — resolution against app bindings happens in the keybinding resolver, + where repo custom command shortcuts take priority over app bindings (branch commit + `1e225271`). +- Resolved keybinding displays shown both in repository settings and on the terminal + toolbar custom command buttons. +- Toolbar overflow menu: the first 3 commands stay as buttons; the rest go into a + scrollable popover (max 10 visible rows). +- SF Symbol preset picker popover (expanded to 30 presets during the branch) while keeping + manual symbol input. +- Execution mode labels renamed to "New Tab" / "In Place" (`1401ff27`). + +## Refs + +- PR #101, fork issue #85 (closed). +- Branch commits: `83243acb`, `22713fe3`, `1e225271`, `bdf6e8d0`, `1401ff27`. + +## Current state + +The table/editor lives in +`supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift` (split out +of `RepositorySettingsView.swift` in PR #403). Overflow rendering is +`CustomCommandOverflowButton` in +`supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift`. Shortcut +migration/resolution is `LegacyCustomCommandShortcutMigration` and +`appResolverSchema(customCommands:)` in `supacode/App/KeybindingSchema.swift`. diff --git a/docs-ai/002-custom-commands/003-split-target-and-close-on-success.md b/docs-ai/002-custom-commands/003-split-target-and-close-on-success.md new file mode 100644 index 00000000..b3c54b3e --- /dev/null +++ b/docs-ai/002-custom-commands/003-split-target-and-close-on-success.md @@ -0,0 +1,44 @@ +# 002 — Amendment: New Split Target + Close on Success (#205) + +## Context + +With two execution targets (New Tab, In Place), a common workflow was missing: run a +command *next to* the current pane — e.g. a dev server or test watcher alongside the agent +session — and have short-lived commands clean up after themselves instead of leaving dead +tabs behind. + +## Change + +PR #205 (merged 2026-04-17, "Custom Command: New Split target + Close on success"): + +- Third execution target **New Split** (`UserCustomCommandExecution.split`): runs the + command in a new pane splitting the focused terminal surface. Each command stores its + own `splitDirection` (default `.right`, matching `Cmd+D`). +- Per-command **Close on success** toggle for the New Tab and New Split targets: when the + command exits `0`, Prowl dismisses the tab/split. One-shot semantics — failure or + non-zero exit leaves the pane open and consumes the flag. +- Setup-script injection is skipped for tabs marked auto-close, so a successful setup + script cannot close the pane before the user command runs. +- Decode compatibility: `UserCustomCommand.init(from:)` uses `decodeIfPresent` with + defaults for the new fields, so older settings files keep decoding. +- Same-branch refinements: auto-close delayed by 800 ms so the final output stays visible + (`5d2c2836`), and a status toast when a Custom Command succeeds (`8b5aa0b5`). + +## Refs + +- PR #205; branch commits `a890fdbb`, `5d2c2836`, `8b5aa0b5`. +- Tests added with the PR: `splitCommandCreatesSplitWithInput`, + `closeOnSuccessFlagIsForwarded`, `userCustomCommandDecodesWithoutNewFields`, + `autoCloseFlagIsConsumedOnSuccess`, `autoCloseFlagIsConsumedOnFailureButDoesNotClose`, + `unmarkedSurfaceDoesNotConsumeAutoCloseState`. + +## Current state + +`TerminalClient.Command.createSplitWithInput` and the `autoCloseOnSuccess` flag on both +create commands (`supacode/Clients/Terminal/TerminalClient.swift`); flagged surfaces +tracked in `WorktreeTerminalState.autoCloseSurfaceIds` and consumed one-shot in +`handleCommandFinished` +(`supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift`), with +cleanup threaded through the surface-close paths in +`WorktreeTerminalState+Surfaces.swift`. The settings UI shows the direction picker only +for `.split` and the toggle only for targets that support it (not In Place). diff --git a/docs-ai/003-diff-window/000-plan.md b/docs-ai/003-diff-window/000-plan.md new file mode 100644 index 00000000..7f9efdda --- /dev/null +++ b/docs-ai/003-diff-window/000-plan.md @@ -0,0 +1,97 @@ +# 003 — Diff Window: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-06 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #1, #10, #45 (follow-up waves: #449, #529, #536, #537, #540) | +| **Sources** | PR descriptions (#1, #10, #45, #449, #529, #536, #537, #540); fork change-list entries for commits `0d03848`–`8985fc2`, `1b32a26` (ledger lives at [017-upstream-sync-process/upstream-ledger.md](../017-upstream-sync-process/upstream-ledger.md)) | +| **Related** | [012-keybinding-system](../012-keybinding-system/000-plan.md), [037-line-diff-tracking](../037-line-diff-tracking/000-plan.md), `docs/components/diff-view.md` | + +## Background + +Prowl's core loop is "let an agent work in a worktree, then review what it did". +Before this feature there was no in-app way to inspect a worktree's uncommitted +changes — reviewing an agent's output meant switching to a terminal or an +external tool. The fork wanted a fast, local diff viewer reachable directly from +the worktree row. + +This is a fork-only feature (upstream supacode has no equivalent), built on +YiTong (`https://github.com/onevcat/YiTong`), onevcat's own WKWebView-backed +diff-rendering library. + +## Goals + +- A standalone diff window showing all changes in the selected worktree's + working directory vs **HEAD** — tracked changes and untracked new files. +- File tree sidebar (left) + rendered diff (right) via `NavigationSplitView`, + with YiTong's `DiffView` as the renderer. +- Instant file switching: preload all file contents concurrently when the + window opens. +- Openable from the worktree row's diff badge, a keyboard shortcut, and a + "Show Diff" menu item. +- Toolbar with sidebar toggle and a split/unified diff style picker persisted + across launches; `Cmd+W` closes the window; window frame persisted. +- Singleton window that refreshes its content when it regains focus. + +**Non-goals** (initial scope) + +- Diff against a base branch or a PR — this is strictly working-tree vs HEAD. +- External diff tools (added later, see amendment 002). +- Staging/committing from the diff window. + +## Design / Approach + +As shipped in #1 (2026-03-06): + +- **Git layer** — new `GitClient` operations: `git diff HEAD --name-status` + (changed-file list), `git ls-files --others --exclude-standard` (untracked + files), and `git show HEAD:<path>` (old file contents). New/deleted/renamed + files map to empty-vs-disk, HEAD-vs-empty, and old-path-vs-new-path pairs. +- **Model** — `DiffChangedFile` parses the `--name-status` output (M/A/D/R/C + status plus paths). +- **State** — `DiffWindowState`, an `@Observable` class holding the file list, + selection, and a per-file `DiffDocument` cache filled by concurrent + preloading, so selecting a file renders from cache. +- **Window** — `DiffWindowManager`, a singleton `NSWindow` manager following + the existing `SettingsWindowManager` pattern: one window app-wide, + `setFrameAutosaveName` for frame persistence, a local `keyDown` event monitor + to intercept `Cmd+W`, and refresh-on-focus. +- **View** — `DiffWindowContentView`: `NavigationSplitView` with the file list + sidebar and YiTong `DiffView` detail; toolbar hosts the sidebar toggle and a + split/unified style picker persisted via `UserDefaults` + (`@AppStorage("diffViewStyle")`). + +Two small fixes were planned/landed as part of the initial arc: unicode +(Chinese) filenames were invisible because git's default `core.quotePath=true` +octal-escapes non-ASCII paths — fixed by passing `-c core.quotePath=false` to +both listing commands (#10); and the YiTong dependency moved from +branch-tracking (`master`) to a semver pin at 0.2.0, whose optimized web bundle +cut the embedded asset from 9.3 MB to 2.7 MB (−7 MB on the .app) (#45). + +## Alternatives & decisions + +- **Standalone `NSWindow`, not a SwiftUI `WindowGroup` scene** — deliberately + followed the existing `SettingsWindowManager` singleton pattern. Consequence: + the window does not inherit SwiftUI environment appearance, which later + required explicit appearance plumbing (amendment 004). +- **Preload everything on open** rather than load-on-select — chosen for + instant file switching; acceptable because worktree diffs are typically + small. The concurrent task group updates the cache per-file as results + arrive, so early selections don't wait for the whole set. +- **YiTong pinned by semver (0.2.0) instead of tracking `master`** (#45) — + reproducible release builds and a measured −71% web-bundle size. +- **Diff basis is HEAD, not the base branch** — the window answers "what did + the agent change that isn't committed yet"; PR-level review is delegated to + code hosts (see `docs/components/github-pull-requests.md`). + +## Amendments + +- Updated 2026-06-14: configurable external diff tools (Hunk, FileMerge, + Kaleidoscope, custom command) — see [002-external-diff-tools.md](002-external-diff-tools.md) +- Updated 2026-07-03: render pipeline hardening — stale-cache race, select + debounce, render-error recovery, `Debouncer` extraction + `RenderState` enum + (#529/#536/#537) — see [003-render-pipeline-hardening.md](003-render-pipeline-hardening.md) +- Updated 2026-07-08: diff window follows app appearance instead of system + (#540) — see [004-appearance-follows-app.md](004-appearance-follows-app.md) diff --git a/docs-ai/003-diff-window/001-action.md b/docs-ai/003-diff-window/001-action.md new file mode 100644 index 00000000..e7f6e480 --- /dev/null +++ b/docs-ai/003-diff-window/001-action.md @@ -0,0 +1,70 @@ +# 003 — Diff Window: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-06 | Git operations for diff (name-status, untracked paths, show file at HEAD); diff window with file tree + YiTong `DiffView`; diff badge click, `Cmd+]` shortcut, Show Diff menu item; preload all file contents on open; toolbar (sidebar toggle, split/unified picker), `Cmd+W` close, frame persistence | PR #1 (commits `0d03848`, `09194c4`, `59dc4f6`, `5850576`, `8985fc2`) | +| 2026-03-19 | Unicode/Chinese filenames: pass `-c core.quotePath=false` to `git diff --name-status` and `git ls-files --others`; unit tests | PR #10 (commit `1b32a26`, fork issue #9) | +| 2026-03-23 | YiTong pinned to 0.2.0 (`upToNextMajorVersion`), optimized web bundle: −6.6 MB bundle, −7 MB .app | PR #45 | +| 2026-06-14 | Configurable external diff tools (Built-in / Hunk / FileMerge / Kaleidoscope / Custom Command) behind one launcher | PR #449 → [002](002-external-diff-tools.md) | +| 2026-07-03 | Fix stale cache writes after cancellation; testable `DiffWindowState` (injected git closures, pure reconciliation statics); render spinner; 150 ms select debounce | PR #529 → [003](003-render-pipeline-hardening.md) | +| 2026-07-03 | Render-failure recovery via `renderGeneration` view identity; leading-edge debounce (first click immediate) | PR #536 → [003](003-render-pipeline-hardening.md) | +| 2026-07-03 | Shared `Debouncer`/`KeyedDebouncer` helpers; render phase folded into `RenderState` enum | PR #537 → [003](003-render-pipeline-hardening.md) | +| 2026-07-08 | Diff window follows the app's appearance setting instead of the system appearance | PR #540 → [004](004-appearance-follows-app.md) | + +## Outcome & current state (as of 2026-07-12) + +The feature lives in `supacode/Features/DiffView/`: + +- `DiffWindowState.swift` — `@Observable @MainActor` store: changed-file list, + per-file `DiffDocument` cache, `RenderState` enum (`idle`/`rendering`/ + `failed`), `renderGeneration` retry counter, leading-edge select debounce via + an injected `Debouncer`, and pure static reconciliation helpers + (`evictedCache`, `resolvedSelection`). Git access is injected as closures + (live implementations use `GitClient`). +- `DiffWindowManager.swift` — singleton `NSWindow` host: + `setFrameAutosaveName("DiffWindow")`, local `keyDown` monitor for `Cmd+W`, + refresh-on-focus, and `NSAppearance.from(_:)` applied from the app's + appearance setting. +- `DiffWindowContentView.swift` — `NavigationSplitView` (file list sidebar + + YiTong `DiffView`), `@AppStorage("diffViewStyle")` split/unified picker, + sidebar toggle (toolbar button + `focusedSceneAction`), render + spinner/error overlay, `.id(state.renderGeneration)` on the `DiffView`, and + `WindowAppearanceSetter` for live appearance updates. +- `DiffChangedFile.swift` — name-status parsing model. + +Supporting pieces: + +- `supacode/Clients/Git/GitClient.swift` — `diffNameStatus(at:)`, + `untrackedFilePaths(at:)`, `showFileAtHEAD(_:in:)`, all with + `-c core.quotePath=false`. +- `supacode/Domain/ExternalDiffTool.swift` and + `supacode/Clients/ExternalDiff/` (`ExternalDiffToolClient.swift`, + `ExternalDiffSnapshotClient.swift`) — the tool setting and launcher; the + Built-in branch calls `DiffWindowManager.shared.show(...)`. +- `supacode/Support/Debouncer.swift` — `Debouncer` + `KeyedDebouncer`, shared + with `WorktreeInfoWatcherManager` and `PullRequestRefreshCoordinator`. +- YiTong is pinned `upToNextMajorVersion: 0.2.0` in `supacode.xcodeproj` + (resolved at 0.2.0). +- Tests: `supacodeTests/DiffWindowStateTests.swift`, + `DebouncerTests.swift`, `ExternalDiffToolTests.swift`, + `GitClientDiffPathEncodingTests.swift`. +- User-facing behavior is documented in `docs/components/diff-view.md`. + +## Deviations from plan + +- The original `Cmd+]` shortcut no longer exists. Show Diff is a config-driven + keybinding (`show_diff` in `supacode/App/AppShortcuts.swift`, default `⌘⇧Y`) + after the keybinding system landed + ([012-keybinding-system](../012-keybinding-system/000-plan.md)). +- "Preload all on open" was refined by the July wave: the cache now survives + `refresh()` (with eviction of disappeared files) instead of being rebuilt + from scratch, and per-file documents stream into the cache as they complete. +- The diff badge / Show Diff entry points no longer open the window directly; + they route through `ExternalDiffToolClient`, which dispatches to the built-in + window or an external tool (amendment 002). + +## Open questions + +None. diff --git a/docs-ai/003-diff-window/002-external-diff-tools.md b/docs-ai/003-diff-window/002-external-diff-tools.md new file mode 100644 index 00000000..97984e2b --- /dev/null +++ b/docs-ai/003-diff-window/002-external-diff-tools.md @@ -0,0 +1,39 @@ +# 003 — Amendment: Configurable External Diff Tools + +## Context + +Users asked to review diffs in their preferred tool instead of the built-in +window (fork issue #322). Terminal-native tools (Hunk) and GUI tools +(FileMerge, Kaleidoscope) have different launch models, and GUI tools cannot +show untracked files from a plain `git diff` invocation. + +## Change + +PR #449 (merged 2026-06-14): + +- A global **Diff Tool** setting with `Built-in`, `Hunk`, `FileMerge`, + `Kaleidoscope`, and `Custom Command` options + (`supacode/Domain/ExternalDiffTool.swift`). Tools not installed on the Mac + are shown disabled in the menu. +- One launcher, `supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift`, + behind both the worktree diff badge and the Show Diff action: + - **Built-in** → `DiffWindowManager.shared.show(...)` (the 000-plan window). + - **Hunk** → opens a Prowl terminal tab and runs `hunk diff` in the worktree. + - **FileMerge / Kaleidoscope / Custom** → `ExternalDiffSnapshotClient` + materializes HEAD/worktree snapshot folders (so untracked files are + included without touching the index) and launches `opendiff`, + `ksdiff --diff`, or the user's command with `{leftPath}`, `{rightPath}`, + `{worktreePath}`, `{repoPath}`, `{branch}` placeholders. +- Settings and behavior documented in `docs/components/diff-view.md` and + `docs/components/settings.md`. + +## Refs + +- PR #449; fork issue #322. +- Tests: `supacodeTests/ExternalDiffToolTests.swift`. + +## Current state + +As described; verified in the working tree 2026-07-12. The launcher is also the +path through which the appearance fix (amendment 004) threads the app's color +scheme into `DiffWindowManager.show()`. diff --git a/docs-ai/003-diff-window/003-render-pipeline-hardening.md b/docs-ai/003-diff-window/003-render-pipeline-hardening.md new file mode 100644 index 00000000..749e8bff --- /dev/null +++ b/docs-ai/003-diff-window/003-render-pipeline-hardening.md @@ -0,0 +1,68 @@ +# 003 — Amendment: Render Pipeline Hardening (2026-07-03) + +## Context + +Three problems accumulated in `DiffWindowState` as the window saw heavier use: + +1. A cancelled diff-load task could still write stale cache/selection data + after a refresh or worktree switch (cancellation was checked after the + write, not before). +2. Rapidly flicking through files (A → B → C) sent a render request for every + pass-through file, causing visible flash/jump; meanwhile the WebView-backed + `DiffView` can take 1–2 s to diff/paint large files even when the + Swift-side cache hit is instant, with no feedback. +3. After a YiTong `didFail` render event, the error overlay could never be + dismissed for the same file: YiTong skips value-equal documents, and + re-selecting the same file was a no-op. + +## Change + +Three PRs in one day, each building on the previous: + +**PR #529 — stale writes, testability, spinner, debounce** + +- Check `Task.isCancelled` *before* cache/selection writes. +- `DiffWindowState` made unit-testable: git access injected as constructor + closures; cache eviction and selection reconciliation extracted into pure + statics (`evictedCache`, `resolvedSelection`). +- `isRenderingDiff` + a centered loading indicator driven by YiTong's + `didRender`/`didFail` events. +- 150 ms `selectFile` debounce backed by an injectable `Clock`. + +**PR #536 — error recovery, leading-edge debounce** + +- Retry after `.didFail` works by bumping `renderGeneration`, which the view + uses as `.id()` on the `DiffView` — recreating the view forces a fresh + render (verified against YiTong 0.2.0 source: `DiffViewController.update` + skips value-equal documents, so merely clearing the error would leave a + stuck spinner). Retry gestures: refresh, or re-selecting the failed file. +- Debounce became leading-edge: a deliberate single click applies immediately + and opens the coalescing window; only rapid follow-ups within the window are + deferred. +- Behavior documented in `docs/components/diff-view.md`. + +**PR #537 — shared `Debouncer`, `RenderState` enum** + +- The cancel-previous + sleep + cancellation-check pattern was hand-rolled in + six places across three stores; extracted into `Debouncer`/`KeyedDebouncer` + (`supacode/Support/Debouncer.swift`) and migrated `DiffWindowState`, + `WorktreeInfoWatcherManager`, and `PullRequestRefreshCoordinator` onto them. +- The migration fixed a latent bug outside the diff window: + `scheduleBranchChanged`/`scheduleRestart` swallowed `CancellationError` with + `try? await sleep(...)`, so a cancelled debounce fired immediately instead + of never. +- `isRenderingDiff: Bool` + `renderError` folded into one `RenderState` enum + (`idle`/`rendering`/`failed`), making "spinner and error at once" + unrepresentable. + +## Refs + +- PRs #529, #536, #537. +- Tests: `supacodeTests/DiffWindowStateTests.swift` (cache eviction, selection + reconciliation, render state, leading-edge debounce via `TestClock`), + `supacodeTests/DebouncerTests.swift`. + +## Current state + +All three changes are live in the working tree as of 2026-07-12; see +[001-action.md](001-action.md) "Outcome & current state" for file-level detail. diff --git a/docs-ai/003-diff-window/004-appearance-follows-app.md b/docs-ai/003-diff-window/004-appearance-follows-app.md new file mode 100644 index 00000000..36137cfd --- /dev/null +++ b/docs-ai/003-diff-window/004-appearance-follows-app.md @@ -0,0 +1,47 @@ +# 003 — Amendment: Diff Window Follows App Appearance (2026-07-08) + +## Context + +The diff window followed the **system** appearance instead of the app's +`appearanceMode` setting: with the system in Light and the app set to Dark, the +diff window rendered with a Light background. + +Root cause traces back to the 000-plan design decision to host the window as a +standalone `NSWindow` (`DiffWindowManager`) rather than a SwiftUI `WindowGroup` +scene: it never read `appearanceMode` from `settingsFile`, so +`.preferredColorScheme()` had no effect. Additionally YiTong's `DiffView` +(WKWebView-backed) used `.automatic` appearance, which resolves +`view.effectiveAppearance` in `viewDidLoad()` — before the view enters the +window hierarchy — returning the system default on first render. + +## Change + +PR #540 (merged 2026-07-08) applies the app's appearance at three levels: + +- **Window chrome** — `DiffWindowManager.show(...)` takes a `colorScheme` + parameter and sets `window.appearance` (via an `NSAppearance.from(_:)` + helper) before `makeKeyAndOrderFront`, for both new and reused windows. +- **Live updates** — `DiffWindowContentView` reads `@Shared(.settingsFile)` + and installs a `WindowAppearanceSetter` in `.background`, the same pattern + as `SettingsView`/`DebugView`, so changing the setting updates the open + window. +- **WKWebView content** — an explicit `appearance` is passed to YiTong's + `DiffConfiguration` instead of `.automatic`, bypassing the too-early + `effectiveAppearance` read. + +`ExternalDiffToolClient` threads the resolved color scheme into +`DiffWindowManager.show()` for the Built-in tool path. + +## Refs + +- PR #540. Files: `supacode/Features/DiffView/DiffWindowManager.swift`, + `supacode/Features/DiffView/DiffWindowContentView.swift`, + `supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift`. + +## Current state + +Verified in the working tree 2026-07-12: `NSAppearance.from(_:)` and the +pre-`makeKeyAndOrderFront` appearance assignment exist in `DiffWindowManager`, +and `DiffWindowContentView` carries `@Shared(.settingsFile)` + +`WindowAppearanceSetter` and passes an explicit appearance to +`DiffConfiguration`. diff --git a/docs-ai/004-prowl-rebrand/000-plan.md b/docs-ai/004-prowl-rebrand/000-plan.md new file mode 100644 index 00000000..e86ddcea --- /dev/null +++ b/docs-ai/004-prowl-rebrand/000-plan.md @@ -0,0 +1,95 @@ +# 004 — Prowl Rebrand: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-17 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #3, #19 | +| **Sources** | PR descriptions; change-list entries for `ea9259f8`, `9970560`, `962ba62`, `5f7d84a`…`5676418` (ledger lives at `docs-ai/017-upstream-sync-process/upstream-ledger.md`); commit messages | +| **Related** | [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md) | + +## Background + +The fork started as a private customization layer over supabitapp/supacode, but it was set +to become an independently distributed app: its own bundle identity, its own settings data, +its own release channel (see [001](../001-fork-bootstrap-and-release-pipeline/000-plan.md)). +Shipping under the upstream name "Supacode" with bundle ID `app.supabit.supacode` had +concrete problems: + +- A user installing both apps would have them fight over the same `~/.supacode` config + directory, the same bundle-ID-scoped state, and the same Sparkle feed — the fork would + receive upstream updates and be overwritten. +- Fork-authored PRs opened by coding agents defaulted to the upstream repository (the + GitHub fork relationship makes `gh pr create` target `supabitapp/supacode` by default), + risking accidental disclosure of fork-private work. + +Groundwork already existed: commit `ea9259f8` (2026-02-28, pre-rebrand) had moved per-repo +settings files out of repository roots (`<repo>/supacode.json`) into +`~/.supacode/repo/<repo-name>/`, with legacy migration — so by rebrand time all fork +settings lived under one relocatable directory. + +## Goals + +- Rename every user-visible identity from "Supacode" to "Prowl": app/window name, menus, + alerts, permission usage strings, settings UI, app icon. +- New bundle ID `com.onevcat.prowl`; new UTType `com.onevcat.prowl.ghosttySurfaceId`. +- Move the config directory `~/.supacode` → `~/.prowl` with transparent migration on first + launch; rename settings files `supacode.json` → `prowl.json` with legacy fallback. +- Detach from upstream's update channel: remove the upstream Sparkle feed URL and signing + key (the fork's own feed comes later via the release pipeline, entry 001). +- Enforce, mechanically, that PRs never target upstream. + +### Non-goals + +- **Renaming code-level identifiers.** The Xcode module, scheme, target, source directory + (`supacode/`), and type prefixes (`SupacodePaths`, `SupaLogger`, …) deliberately stay + `supacode`-named. This is a recorded decision, not an oversight: every rename in code is + a permanent merge-conflict surface against upstream, and the fork syncs upstream + continuously (entry [017](../017-upstream-sync-process/000-plan.md)). + +## Design / Approach + +1. **String/identity sweep** (`supacode/Info.plist`, `supacode/App/supacodeApp.swift`, + feature views, `supacode.xcodeproj/project.pbxproj`): display strings → "Prowl", + `PRODUCT_BUNDLE_IDENTIFIER` → `com.onevcat.prowl`, `PRODUCT_NAME` → `Prowl`, with an + explicit `PRODUCT_MODULE_NAME = supacode` so `import`/`@testable import supacode` and + the test host keep working after the product rename. +2. **Config directory migration** in `supacode/Support/SupacodePaths.swift`: the + `baseDirectory` getter checks for `~/.prowl`; if absent but `~/.supacode` exists, it + migrates the whole directory on first access. As originally shipped in #3 this used + `moveItem` (see Amendments — changed to copy). +3. **Settings file rename with fallback chain**: per-repo settings load `prowl.json` + first, falling back to legacy `supacode.json` (and rewriting to the new name on + successful legacy load), so existing users migrate transparently. +4. **Update-channel detach**: delete `SUFeedURL` (`https://supacode.sh/...`) and + `SUPublicEDKey` from `Info.plist` so the fork can never install an upstream build over + itself. +5. **PR-target guard**: a Claude Code `PreToolUse` hook + (`.claude/hooks/block-upstream-pr.sh`, wired in `.claude/settings.json`) inspects every + Bash tool call; any `gh pr create` that does not explicitly pass `--repo`/`-R` pointing + at the fork is blocked (exit 2) before execution. A matching prose rule was added to + `AGENTS.md`. Defense in depth: the rule tells agents what to do, the hook makes the + wrong default impossible. + +## Alternatives & decisions + +- **Full rename vs. user-facing-only rename**: full rename (module, types, directories) + was rejected for merge compatibility; only what users see was renamed. The change-list + ledger records this as "Keep module name as `supacode` for code compatibility". This + decision still pays off: upstream diffs to `supacode/**` apply without path rewrites. +- **Move vs. copy for `~/.supacode` migration**: #3 shipped `moveItem`. Fork issue #16 + showed this deletes the data of a co-installed upstream Supacode; #19 changed it to + `copyItem` (see [002-migration-copy-not-move.md](002-migration-copy-not-move.md)). +- **Hook scope**: the guard only intercepts `gh pr create`, and only requires an explicit + fork `--repo` flag — it does not try to parse every possible gh invocation. Simplicity + over completeness; `AGENTS.md` covers intent. +- **Sparkle**: disabled rather than re-pointed at rebrand time; the fork feed + (`https://github.com/onevcat/Prowl/releases/latest/download/appcast.xml`) was wired + later by the release-pipeline work (entry 001). + +## Amendments + +- Updated 2026-03-20: migration changed from move to copy so a co-installed upstream + Supacode keeps `~/.supacode` — see + [002-migration-copy-not-move.md](002-migration-copy-not-move.md) diff --git a/docs-ai/004-prowl-rebrand/001-action.md b/docs-ai/004-prowl-rebrand/001-action.md new file mode 100644 index 00000000..44a6b8aa --- /dev/null +++ b/docs-ai/004-prowl-rebrand/001-action.md @@ -0,0 +1,70 @@ +# 004 — Prowl Rebrand: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-02-28 | Groundwork: per-repo settings files moved from repo roots into `~/.supacode/repo/<name>/` with legacy migration | commit `ea9259f8` | +| 2026-03-17 | User-facing rebrand: display strings, bundle ID `com.onevcat.prowl`, UTType `com.onevcat.prowl.ghosttySurfaceId`, `~/.supacode`→`~/.prowl` migration (move), `supacode.json`→`prowl.json` fallback chain, upstream Sparkle feed + EdDSA key removed | PR #3 (`5f7d84ae`) | +| 2026-03-17 | AGENTS.md rule: PRs always target the fork, never upstream | commit `962ba621` | +| 2026-03-17 | PreToolUse hook blocking `gh pr create` without an explicit fork `--repo` | commit `99705600` | +| 2026-03-17 | New Prowl cat app icon (all AppIcon sizes) + test assertions updated to `prowl.json` | commit `dfd04ef7` | +| 2026-03-17 | `PRODUCT_NAME` renamed `supacode`→`Prowl`; then `TEST_HOST` fixed to `Prowl.app`, explicit `PRODUCT_MODULE_NAME = supacode`, shared xcscheme added | commits `83113df6`, `5676418d` | +| 2026-03-18 | Hook updated to also accept `onevcat/Prowl` (GitHub repo renamed from `onevcat/supacode`) | commit `3d72fb7c` | +| 2026-03-20 | Migration changed from `moveItem` to `copyItem` to preserve `~/.supacode` (fork issue #16) | PR #19 — see [002](002-migration-copy-not-move.md) | +| 2026-04-13 | Hook and AGENTS.md drop the old `onevcat/supacode` name; `onevcat/Prowl` is the only valid PR target | commit `fd637b14` | + +## Outcome & current state (as of 2026-07-12) + +- **Identity**: `supacode.xcodeproj/project.pbxproj` has + `PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl`, `PRODUCT_NAME = Prowl`, and + `PRODUCT_MODULE_NAME = supacode` for the Release configuration. Debug builds later + gained a distinct identity (`com.onevcat.prowl.debug`, `PRODUCT_NAME = "Prowl Debug"`) + so a dev build can run alongside the installed app — that is entry + [016](../016-dev-build-and-ci-workflow/000-plan.md) work, not part of this rebrand. + The main window title and menu strings use "Prowl" (`supacode/App/supacodeApp.swift`). +- **Paths**: `supacode/Support/SupacodePaths.swift` — `baseDirectory` copy-migrates + `~/.supacode` → `~/.prowl` on first access (`copyItem`, per #19); + `appSupportDirectory` is `~/Library/Application Support/com.onevcat.prowl`. +- **Settings files**: `repositorySettingsURL` → `prowl.json`, + `userRepositorySettingsURL` → `prowl.onevcat.json`, with legacy fallbacks + `supacode.json` / `supacode.onevcat.json` still honored (and rewritten to the new name + on load) in `supacode/Features/Settings/BusinessLogic/RepositorySettingsKey.swift` and + `UserRepositorySettingsKey.swift` (the latter renamed from + `OnevcatRepositorySettingsKey.swift` in later refactoring). +- **Logging subsystem**: `supacode/Support/SupaLogger.swift` uses + `Bundle.main.bundleIdentifier ?? "com.onevcat.prowl"`; `make log-stream` filters on + `com.onevcat.prowl`. +- **Sparkle**: `supacode/Info.plist` again contains `SUFeedURL` — now pointing at the + fork's own appcast (`https://github.com/onevcat/Prowl/releases/latest/download/appcast.xml`) + with the fork's `SUPublicEDKey`. The rebrand removed the upstream feed; the fork feed + was restored by the release pipeline + ([001](../001-fork-bootstrap-and-release-pipeline/000-plan.md)). +- **Guard hook**: `.claude/hooks/block-upstream-pr.sh` is active via the `PreToolUse` + Bash matcher in `.claude/settings.json`; it blocks any `gh pr create` that does not + explicitly pass `--repo`/`-R` `onevcat/Prowl`. `AGENTS.md` carries the matching prose + rule ("PRs must target `onevcat/Prowl` … never the upstream `supabitapp/supacode`"). +- **Module naming**: source directory, scheme, and module remain `supacode` — paths like + `supacode/App/...` are correct and intentional. + +## Deviations from plan + +- The migration strategy described in #3 (move `~/.supacode` wholesale) survived only + three days; #19 replaced it with copy after fork issue #16. Documented as amendment + [002](002-migration-copy-not-move.md). +- #3 described the bundle ID change as complete, but a distinct Debug bundle ID was + introduced much later (entry 016); at rebrand time Debug and Release shared + `com.onevcat.prowl`. + +## Open questions + +- `SupacodePaths.originalLegacyRepositorySettingsURL(for:)` and + `originalLegacyUserRepositorySettingsURL(for:)` (repo-root `supacode.json` / + `supacode.onevcat.json` locations, `supacode/Support/SupacodePaths.swift:290-297`) are + defined but referenced nowhere in the app, CLI, or tests — the repo-root fallback read + appears to have been dropped from the load chain at some point, leaving these as dead + code. Candidates for removal. +- `Info.plist` sets `SUEnableAutomaticChecks` to `false` even though the fork feed is + configured; whether background update checks are driven elsewhere is a question for + entry [021](../021-sparkle-update-ux/000-plan.md), noted here only because the key + originates in this file. diff --git a/docs-ai/004-prowl-rebrand/002-migration-copy-not-move.md b/docs-ai/004-prowl-rebrand/002-migration-copy-not-move.md new file mode 100644 index 00000000..eb308723 --- /dev/null +++ b/docs-ai/004-prowl-rebrand/002-migration-copy-not-move.md @@ -0,0 +1,33 @@ +# 004 — Amendment: Copy, Don't Move, `~/.supacode` + +## Context + +PR #3's first-launch migration moved `~/.supacode` to `~/.prowl` with +`FileManager.moveItem`. Fork issue #16 (2026-03-20) reported the consequence for users +running both apps: launching Prowl while upstream Supacode was installed moved the entire +directory — including `repos/<repo>/` worktrees — leaving Supacode with no data and its +worktrees untracked. + +## Change + +PR #19 (merged 2026-03-20) changed `moveItem` to `copyItem` in +`SupacodePaths.baseDirectory`. First launch now duplicates `~/.supacode` into `~/.prowl` +and leaves the original untouched, so a co-installed upstream Supacode keeps working. +The migration still only fires when `~/.prowl` does not exist yet, so it runs at most +once; after that the two apps' state diverges independently. + +Trade-off accepted: the two directories are a fork, not a sync — changes made in Supacode +after Prowl's first launch are not seen by Prowl, and disk usage doubles for the copied +data (including any worktrees stored under the default `~/.prowl/repos/` base). This was +judged correct: silently destroying another app's data is worse than a one-time copy. + +## Refs + +- PR #19 "Fix migration: copy instead of move to preserve ~/.supacode" +- Fork issue #16 "Prowl moves everything in .supacode to .prowl" + +## Current state + +`supacode/Support/SupacodePaths.swift` — `baseDirectory` still uses +`try? FileManager.default.copyItem(at: legacyDir, to: prowlDir)` guarded by +"`~/.prowl` missing and `~/.supacode` present". Unchanged since #19. diff --git a/docs-ai/005-canvas-live-sessions/000-plan.md b/docs-ai/005-canvas-live-sessions/000-plan.md new file mode 100644 index 00000000..235ef0e9 --- /dev/null +++ b/docs-ai/005-canvas-live-sessions/000-plan.md @@ -0,0 +1,99 @@ +# 005 — Canvas (Live Sessions) v1: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-17 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #2, #6, #7, #8, #11, #43, #44, #54 | +| **Sources** | PR descriptions, commit history (`2c1d9aa`…`80df1b1`, `15bafd1`…`fc81375`, `38a6361`…`d9dde25`), fork change ledger ([upstream-ledger](../017-upstream-sync-process/upstream-ledger.md)) | +| **Related** | [009-terminal-surface-lifecycle](../009-terminal-surface-lifecycle/000-plan.md), [011-canvas-multiselect-broadcast](../011-canvas-multiselect-broadcast/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md), [043-canvas-tile-layout](../043-canvas-tile-layout/000-plan.md), `docs/components/canvas.md` | + +## Background + +Prowl's core use case is running many coding agents in parallel, one per worktree tab. +The normal view shows a single worktree at a time, so there was no way to *watch* all +agents at once — checking on sessions meant cycling through worktrees. The fork-only +answer was a free-form canvas showing every open terminal tab as a live card. The +feature was initially built as "Dashboard (Live Sessions)" and renamed to Canvas +mid-branch (commit `74b13c2`) before PR #2 merged. + +This entry covers Canvas v1 only (2026-03-17 → 2026-03-25). Later interaction work is +[024](../024-canvas-interaction-evolution/000-plan.md), multi-select broadcast is +[011](../011-canvas-multiselect-broadcast/000-plan.md), the Tile layout is +[043](../043-canvas-tile-layout/000-plan.md), and the canvas-exit blank-surface +investigation that Canvas triggered is [009](../009-terminal-surface-lifecycle/000-plan.md). + +## Goals + +- Show **every open tab of every worktree** as a live, draggable, resizable card + (per-tab cards, not per-worktree), rendering the tab's full split-pane layout. +- Cards host the *real* terminal surfaces — the same `GhosttySurfaceView` instances the + tab view uses — so Canvas is a viewport, not a snapshot. +- Zooming the canvas must **not reflow terminals** (no PTY resize storm under scale). +- Navigation: cursor-anchored pinch zoom, two-finger scroll panning, fit-to-view. +- Arrange affordances: reset to a uniform grid (**Organize**) and a size-preserving + compact packing (**Arrange**). +- Fast toggle (⌥⌘↩) with focus continuity in both directions: entering Canvas focuses + the card for the previously active worktree+tab; exiting returns to the worktree+tab + focused in Canvas. + +**Non-goals** (deferred, later delivered elsewhere): multi-select + input broadcast +(011), viewport-filling tile layout (043), card z-order/layout-order persistence +refinements, hover controls and expand-in-place (024). + +## Design / Approach + +- **View layer** (`supacode/Features/Canvas/`): `CanvasView` renders cards positioned by + `CanvasCardLayout` values held in `CanvasLayoutStore` (UserDefaults-backed, key + `canvasCardLayouts`), so positions/sizes persist across launches. `CanvasCardView` + draws one card: title bar (drag handle) plus the tab's live split tree. +- **Shared surfaces**: cards obtain the live views via + `WorktreeTerminalState.surfaceView(for:)` and reparent them; per-tab focus, resize, + and occlusion are managed per card. This one-surface-many-hosts design is what later + made exit-blank reattachment bugs possible (see 009). +- **Reflow prevention**: an optional `pinnedSize` is threaded through + `TerminalSplitTreeView` down to each leaf `GhosttySurfaceView`, keeping surface sizes + fixed while the canvas is scaled with `.scaleEffect()`; card positioning uses offsets + rather than `.position()` to keep zoom transforms out of terminal layout. +- **Gestures**: `CanvasScrollContainer` (an `NSViewRepresentable`) intercepts + scroll-wheel events not consumed by a focused card and turns them into canvas panning; + pinch zoom is anchored at the cursor (`CanvasZoomMath`); fit-to-view computes the + scale/offset enclosing all cards (`CanvasViewportMath`). +- **Arrange packing**: Organize resets all cards to a uniform grid. Arrange preserves + each card's size and packs positions compactly. The packer went through three + algorithms in two days (waterfall columns → MaxRects-BSSF → exhaustive hybrid) — see + Alternatives. Auto-arrange runs once per app session on first Canvas entry, and + re-arms when all tabs are closed. +- **Toggle & focus restoration** (#11): reducer-level `toggleCanvas`; the terminal layer + tracks `canvasFocusedWorktreeID` on `WorktreeTerminalManager` so the reducer can exit + to the card the user focused. The ⌥⌘↩ shortcut is explicitly unbound in Ghostty so the + terminal never swallows it. + +## Alternatives & decisions + +- **Arrange packer: MaxRects rejected for exhaustive search** (PR #8 branch). The first + Arrange (#7) used waterfall/masonry columns; `15bafd1` replaced it with MaxRects-BSSF + bin packing; within the same branch that was replaced again by an exhaustive + evaluation of waterfall (1…N columns) and row-break (2^(N-1) masks, N ≤ 20) layouts, + scoring each by the resulting fit-to-view scale and keeping the best. Decision: for + N ≤ 20 cards, exhaustively optimizing the actual objective (on-screen scale) beats a + bin-packing heuristic optimizing area. +- **Per-tab cards over per-worktree cards**: the initial branch showed one card per + worktree; `e5992ea` (still pre-merge) switched to one card per open tab, which became + the model everything later (broadcast, tile, spatial navigation) builds on. +- **Auto-arrange once per session, not on every entry** (`e698c1f`…`9aea73d`): manual + positions are user data; automatic layout only runs when there is nothing worth + preserving (first entry, or after all tabs were closed). +- **Canvas shortcut owned by the app, not Ghostty** (#11): ⌥⌘↩ is force-unbound in the + embedded Ghostty config so toggling works while a terminal has focus. +- **Cmd+W keeps terminal close semantics inside Canvas** (#54): after an upstream sync + added a window-level Close Window on Cmd+W, Canvas re-exposed focused + close-surface/close-tab actions so the shortcut closes the focused pane/card, not the + app window — see [002-cmd-w-close-semantics.md](002-cmd-w-close-semantics.md). + +## Amendments + +- Updated 2026-03-25: restore Cmd-W close-surface/close-tab semantics in Canvas after + upstream's Close Window shortcut took over (#54) — see + [002-cmd-w-close-semantics.md](002-cmd-w-close-semantics.md) diff --git a/docs-ai/005-canvas-live-sessions/001-action.md b/docs-ai/005-canvas-live-sessions/001-action.md new file mode 100644 index 00000000..d4cdf470 --- /dev/null +++ b/docs-ai/005-canvas-live-sessions/001-action.md @@ -0,0 +1,75 @@ +# 005 — Canvas (Live Sessions) v1: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-15…17 | Feature branch: "Dashboard (Live Sessions)" free-form canvas; drag/resize/gesture fixes; renamed to Canvas (`74b13c2`); zoom-reflow fixes (`.offset()` positioning, `pinnedSize`); cursor-anchored pinch zoom (`c24e092`); two-finger pan (`2738c24`); all open tabs as per-tab cards (`e5992ea`); batched grid positioning (`80df1b1`); split panes in cards with `pinnedSize` propagation (`12496d5`) | PR #2 (merged 03-17) | +| 2026-03-17 | SwiftLint cleanup in `CanvasView` | PR #5 | +| 2026-03-18 | Sidebar button bleed-through/centering fixes; Canvas toolbar title as plain label | PR #6 | +| 2026-03-18 | Default card size 600×400 → 800×550; max resize 1200×900 → 2400×1600; first **Arrange** button (size-preserving waterfall packing) next to **Organize** | PR #7 | +| 2026-03-19 | Arrange packer rework: MaxRects-BSSF (`15bafd1`) → exhaustive row-break (`aca935d`) → waterfall + row-break hybrid maximizing fit-to-view scale (`fc81375`), extracted as `CanvasCardPacker` with unit tests; auto-arrange on first Canvas entry per session, re-armed when all tabs close | PR #8 | +| 2026-03-19 | ⌥⌘↩ toggles Canvas with two-way worktree+tab focus restoration; `canvasFocusedWorktreeID` introduced on the terminal manager; Canvas + Show Diff moved to the View menu; shortcut unbound in Ghostty | PR #11 | +| 2026-03-23 | Double-click on a card title bar exits Canvas into that tab (first click focuses; interval from `NSEvent.doubleClickInterval`) | PR #43 | +| 2026-03-23 | Arrange/Organize transitions animated (`withAnimation(.easeInOut(duration: 0.2))`) | PR #44 | +| 2026-03-25 | Cmd-W in Canvas restored to close-surface/close-tab semantics after upstream's Close Window command claimed the shortcut | PR #54, [002](002-cmd-w-close-semantics.md) | + +The first exit-blank-surface fix (#42, 2026-03-23) landed in the same window but is +tracked as the opening of [009-terminal-surface-lifecycle](../009-terminal-surface-lifecycle/001-action.md). + +## Outcome & current state (as of 2026-07-12) + +Canvas lives in `supacode/Features/Canvas/`, now split into `Models/` and `Views/`: + +- `Views/CanvasView.swift` (+ `CanvasView+Focus.swift`): card collection, gestures, + `organizeCards()` / `arrangeCards()` / `tileCards()` and their animated `*WithFit()` + wrappers, double-click title-bar handling. +- `Views/CanvasCardView.swift`: per-card chrome + live split tree with `pinnedSize`. +- `Views/CanvasSupportViews.swift`: `CanvasScrollContainer`, `CanvasZoomMath`, + `CanvasViewportMath`, `CanvasViewportAnimator` (viewport animation came later). +- `Views/CanvasSidebarButton.swift`, `Views/CanvasHelpButton.swift`. +- `Models/CanvasCardLayout.swift`: `CanvasCardLayout`, the hybrid `CanvasCardPacker` + (tests in `supacodeTests/CanvasCardPackerTests.swift`), `CanvasTileLayout` (from 043), + and `CanvasLayoutStore` including `hasAutoArrangedInSession` / + `shouldAutoArrangeOnInitialEntry(for:)` from PR #8. +- `Models/CanvasSelectionState.swift`, `CanvasSpatialNavigation.swift`, + `CanvasFocusRequest.swift`, `CanvasExpandGeometry.swift` are later additions + (011 / 024), not v1. + +v1 mechanisms still in place: + +- `pinnedSize` still threads `TerminalSplitTreeView` → + `Infrastructure/Ghostty/GhosttyTerminalView.swift` → `GhosttySurfaceView.swift`. +- `WorktreeTerminalState.surfaceView(for:)` + (`supacode/Features/Terminal/Models/WorktreeTerminalState.swift`) remains the card → + surface bridge. +- `canvasFocusedWorktreeID` on `WorktreeTerminalManager` outgrew focus restoration: it + now also drives the Canvas toolbar target, reducers, and CLI target resolution + (`supacode/CLIService/TargetResolver.swift`). +- The ⌥⌘↩ default survives as `AppShortcuts.toggleCanvas` (command id `toggle_canvas` + in `supacode/App/AppShortcuts.swift`), now resolved through the config-driven + keybinding system ([012](../012-keybinding-system/000-plan.md)) rather than a + hardcoded menu shortcut. +- Cmd-W routing per [002](002-cmd-w-close-semantics.md): + `supacode/Features/Repositories/Views/WorktreeDetailView.swift` publishes + close-surface/close-tab focused actions for the selected *or canvas-focused* + worktree, consumed by `supacode/Commands/TerminalCommands.swift`. + +Superseded v1 details: the fixed 800×550 default card size gave way to a screen-derived +`adaptiveDefaultCardSize` (024, #401); a third layout mode (Tile) joined +Organize/Arrange (043); Organize/Arrange/Tile gained keyboard shortcuts and palette +commands (024). User-facing behavior is documented in `docs/components/canvas.md`. + +## Deviations from plan + +- The Arrange algorithm was not stable at ship time — waterfall (#7) was rewritten into + the hybrid packer (#8) the next day. Recorded as a decision in 000-plan. +- PR #2's initial per-worktree card model changed to per-tab cards before merge + (`e5992ea`); the merged v1 already matched the per-tab goal. + +## Open questions + +- Stale doc comment: `arrangeCards()` in `supacode/Features/Canvas/Views/CanvasView.swift` + still says "Arrange cards using MaxRects-BSSF bin packing", but the implementation + delegates to `CanvasCardPacker`, the waterfall + row-break hybrid; MaxRects was + replaced inside the PR #8 branch. Comment-only inaccuracy. diff --git a/docs-ai/005-canvas-live-sessions/002-cmd-w-close-semantics.md b/docs-ai/005-canvas-live-sessions/002-cmd-w-close-semantics.md new file mode 100644 index 00000000..ab90e832 --- /dev/null +++ b/docs-ai/005-canvas-live-sessions/002-cmd-w-close-semantics.md @@ -0,0 +1,36 @@ +# 005 — Amendment: Cmd-W close semantics in Canvas (2026-03-25) + +## Context + +Upstream commit `b78042c` (picked up via fork sync) added a **Close Window** command +bound to Cmd+W. Correct for the main app window, but Canvas relies on terminal close +semantics for the same shortcut: close the focused split pane if one exists, otherwise +close the focused card/tab. Because Canvas did not expose a non-nil focused +close-surface action, the new Window command won the shortcut overlap — pressing Cmd+W +inside Canvas closed the entire app window. + +## Change + +Restore Canvas-specific focused actions while preserving upstream's Close Window for +the non-Canvas case: + +- Route Canvas Cmd+W through the **currently focused canvas worktree** (tracked as + `canvasFocusedWorktreeID`, from #11) instead of the selected worktree, which is + cleared while Canvas is showing. +- Expose non-nil `closeSurfaceAction` / `closeTabAction` focused scene values whenever + a canvas card has focus, so the terminal close menu commands out-prioritize the + Window command on the shared shortcut. +- Main terminal view behavior unchanged. + +## Refs + +- PR #54 (merged 2026-03-25) + +## Current state + +Still the mechanism in use: `supacode/Features/Repositories/Views/WorktreeDetailView.swift` +computes the action target as the selected terminal worktree, falling back to the +canvas-focused one, and publishes `.focusedSceneValue(\.closeTabAction, …)` / +`.focusedSceneValue(\.closeSurfaceAction, …)`; `supacode/Commands/TerminalCommands.swift` +consumes those focused values for the Close menu commands (shortcut display resolved +via Ghostty bindings). diff --git a/docs-ai/006-startup-performance/000-plan.md b/docs-ai/006-startup-performance/000-plan.md new file mode 100644 index 00000000..6c88a7bf --- /dev/null +++ b/docs-ai/006-startup-performance/000-plan.md @@ -0,0 +1,92 @@ +# 006 — Startup Performance: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-19 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #13, #15, #17, #18 | +| **Sources** | `doc-onevcat/plans/2026-03-20-repository-snapshot-cache-design.md` (absorbed here; original removed in the docs-ai migration), PR descriptions, change-list entries | +| **Related** | [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md), [039-gh-cli-hardening](../039-gh-cli-hardening/000-plan.md), `docs-ai/017-upstream-sync-process/upstream-ledger.md` | + +## Background + +With many repositories added (benchmark: 13 repos on Apple Silicon), app launch took ~4.8s +before the UI was usable. Three compounding costs: + +- Repository/worktree discovery (`loadRepositoriesData` in `RepositoriesFeature`) ran as a + serial `for` loop over all repositories. +- Every bundled `wt` invocation (`wt root`, `wt ls --json`) spawned a login shell to resolve + the GUI environment, paying shell startup cost per call. +- The sidebar rendered nothing until the full live discovery pass finished. + +## Goals + +- Make the UI usable near-instantly on launch, even with many repositories. +- Parallelize discovery without breaking repository order or last-focused selection restore. +- Remove the per-invocation login-shell overhead from bundled `wt` execution. +- Restore repository UI immediately from a small startup cache while keeping live discovery + as the only source of truth. + +### Non-goals + +Per the snapshot-cache design doc: + +- Do not cache PR state, line changes, watcher state, notifications, or + `lastFocusedRepositoryID` (selection restore keeps using existing `lastFocusedWorktreeID` + persistence). +- No TTL/freshness machinery — the cache is only a startup accelerator; freshness comes + from the unconditional live refresh that always runs after restore. + +## Design / Approach + +Three stacked optimizations, landed over two days: + +1. **Parallel repository loading** (#13, reworked as #15). Replace the serial loop with a + `TaskGroup` fanning out one worktree-discovery task per repository. #13 additionally + loaded the last-focused repository first (UI usable at 0.39s vs 4.83s, ~12x) and added + startup benchmark logging; #15 re-landed the parallelization in a simpler form that + preserves persisted repository/root order in the final snapshot even when fetches + complete out of order, with tests for order preservation and last-focused selection + restore. +2. **Direct bundled `wt` execution** (#17). Run the bundled `wt` binary directly via + `ShellClient` instead of always going through a login shell; fall back to login-shell + execution only for obvious GUI environment resolution failures. Implemented in + `supacode/Clients/Git/GitClient.swift`. +3. **Repository snapshot startup cache** (#18), per the absorbed design doc: + - **Storage**: standalone JSON file at `~/.prowl/repository-snapshot.json` (deliberately + not inside `settings.json`) so cache decode failures stay isolated from settings, the + file is safely deletable, and the payload can evolve behind an explicit schema version. + - **Payload**: only data needed for first paint — repositories in UI order, root path, + display name; per worktree: name, detail string, working-directory path, `createdAt`. + - **Invalidation**: treat as a miss (discard the file, run a normal live load) when the + file is missing/empty, the schema version mismatches, JSON decoding fails, or any + cached repository root / worktree path no longer exists on disk. + - **Startup flow**: load persisted pinned/archive/order/last-focused state → load the + snapshot → if present, restore repositories into state immediately and mark initial + load complete so the main UI renders → always run the normal live loading flow → + apply live results. + - **Refresh rules**: overwrite the snapshot only after a complete successful live load + (initial refresh, manual refresh, or any flow ending in a full successful snapshot); + never on partial or failed loads. + +## Alternatives & decisions + +- **Priority loading vs snapshot cache for first paint**: #13's "load last-focused repo + first, rest in background" approach shipped first but was superseded within a day — its + merge commit is not part of current `main` history, and #15 re-implemented the parallel + load without the priority phase or benchmark logging. Instant first paint is delivered by + the snapshot cache (#18) instead, which restores *all* repositories at once. +- **Standalone cache file vs settings.json**: standalone file chosen so bad cache data can + never affect settings loading and the cache stays disposable with no migration burden. +- **No TTL**: rejected as unnecessary; the unconditional post-restore live refresh is the + freshness mechanism. +- **Upstream contribution**: the two generally-useful optimizations (#15 parallel loading, + #17 direct `wt`) were contributed and merged upstream; the snapshot cache was offered but + not accepted, so it remains fork-only code. See amendment 002. + +## Amendments + +- Updated 2026-03-22: parallel loading and direct `wt` merged upstream (upstream #160/#161); + snapshot cache upstream PR closed unmerged — see + [002-upstream-contribution.md](002-upstream-contribution.md) diff --git a/docs-ai/006-startup-performance/001-action.md b/docs-ai/006-startup-performance/001-action.md new file mode 100644 index 00000000..f906bac2 --- /dev/null +++ b/docs-ai/006-startup-performance/001-action.md @@ -0,0 +1,73 @@ +# 006 — Startup Performance: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-19 | Parallel worktree loading with last-focused-repo priority + startup benchmark logging (UI usable 4.83s → 0.39s on 13 repos); closed fork issue #12 | PR #13 | +| 2026-03-20 | Order-preserving parallel repository loading re-landed via `TaskGroup`, superseding #13 (whose merge commit dropped out of `main` history); tests for root-order preservation and last-focused selection restore | PR #15 | +| 2026-03-20 | Bundled `wt root` / `wt ls --json` executed directly instead of via login shell, with login-shell fallback only for GUI environment resolution failures | PR #17 | +| 2026-03-20 | Repository snapshot startup cache: restore-before-live-refresh, save only after full successful loads, validation/discard rules, design doc committed | PR #18 | +| 2026-03-22 | #15 and #17 contributed and merged upstream; #18 offered upstream but closed unmerged | upstream #160, #161, #162 — see [002](002-upstream-contribution.md) | +| 2026-03-31 | Snapshot file relocated from `~/.prowl/` to the Application Support cache directory as part of persistence-safety hardening | PR #112 (see [014](../014-terminal-layout-persistence/000-plan.md)) | + +## Outcome & current state (as of 2026-07-12) + +- Parallel loading lives in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift`: + `loadRepositoriesData` fans out one `withTaskGroup` task per persisted entry, collects + results keyed by normalized root ID, then reassembles them in persisted entry order — + #15's order-preservation design, since extended to plain folders and project workspaces + (`PersistedRepositoryEntry.kind`, `ProjectWorkspace`). `upgradedRepositoryEntriesIfNeeded` + in the same file also runs entry upgrades in parallel. No last-focused priority phase and + no benchmark logging exist in the current tree (#13's additions did not survive). +- Direct `wt` execution lives in `supacode/Clients/Git/GitClient.swift`: + `runBundledWtProcess` runs the bundled script (`wtScriptURL()` resolves it from the + app bundle's `git-wt` resources, backed by the `Resources/git-wt` submodule) via + `shell.run`, falling back to `shell.runLogin` when `shouldFallbackToLoginShell` + (`supacode/Clients/Git/GitClientShellHelpers.swift`) allows. Fallback semantics were + later reworked (invert-fallback for git detection, #493/#541) — see + [039-gh-cli-hardening](../039-gh-cli-hardening/000-plan.md). +- The snapshot cache lives in + `supacode/Clients/Repositories/RepositoryPersistenceClient.swift`: + `loadRepositorySnapshot`/`saveRepositorySnapshot` endpoints plus + `RepositorySnapshotCachePayload` — now schema version 2 with hard caps (2 MiB file, + 256 repositories, 512 worktrees per repository) and payload extended with repository + `kind` and `workspace` for plain folders/workspaces. Any validation failure discards the + cache file, exactly as designed. +- Storage moved: `SupacodePaths.repositorySnapshotURL` + (`supacode/Support/SupacodePaths.swift`) now points into + `~/Library/Application Support/com.onevcat.prowl/cache/repository-snapshot.json`; + `migrateLegacyCacheFilesIfNeeded` migrates the legacy `~/.prowl/repository-snapshot.json` + on launch. +- The startup flow in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` matches + the design: `.task` sets `snapshotPersistencePhase = .restoring`, loads persisted state + plus the snapshot, `.repositorySnapshotLoaded` applies restored repositories and sets + `isInitialLoadComplete = true`, then `.loadPersistedRepositories` runs the live refresh; + the snapshot is saved only when a live load completes with no failures (also enforced in + `RepositoriesFeature+RepositoryManagement.swift`). +- Tests: `supacodeTests/RepositoriesFeatureTests.swift`, + `supacodeTests/RepositoryPersistenceClientTests.swift`, + `supacodeTests/RepositoriesFeaturePersistenceTests.swift`. + +## Deviations from plan + +- #13's priority loading and benchmark logging shipped but were dropped: its merge commit + (`833bb54e`) is not an ancestor of current `main`, and #15 re-implemented parallel + loading on the pre-#13 base without them. First-paint latency is covered by the snapshot + cache instead. +- Snapshot storage location deviates from the design doc's `~/.prowl/repository-snapshot.json`: + moved to the Application Support cache directory by PR #112 (entry 014), with legacy + migration. +- Snapshot schema evolved past the designed payload: version 2 adds repository `kind` and + `workspace`, and size caps were introduced that the design doc did not specify. + +## Open questions + +- Why PR #13 disappeared from `main` history is undocumented: GitHub shows it merged + (merge commit `833bb54e`), but that commit is unreachable from `main` and #15 was built + on the pre-#13 base — presumably a deliberate branch reset before re-landing, but no + revert commit or note records this. +- The upstream review ledger still marks the snapshot cache as "Pending upstream (#162)", + but upstream #162 is closed unmerged — the ledger row is stale. diff --git a/docs-ai/006-startup-performance/002-upstream-contribution.md b/docs-ai/006-startup-performance/002-upstream-contribution.md new file mode 100644 index 00000000..10729b89 --- /dev/null +++ b/docs-ai/006-startup-performance/002-upstream-contribution.md @@ -0,0 +1,29 @@ +# 006 — Startup Performance: Amendment — Upstream Contribution Outcome + +## Context + +Immediately after landing in the fork, the two generally-useful startup optimizations were +offered to upstream `supabitapp/supacode` from dedicated contribution branches +(`contrib/parallel-loading`, `contrib/direct-bundled-wt`); the snapshot cache was offered +as well. + +## Change + +- upstream #160 "Parallelize repository startup loading" — merged upstream 2026-03-22 + (fork PR #15's change re-landed as commit `8dd8eac5`). +- upstream #161 "Run bundled wt discovery directly" — merged upstream 2026-03-22 + (fork PR #17's change re-landed as commit `ed27b311`). +- upstream #162 "Add repository snapshot startup cache" — closed unmerged; the snapshot + cache (fork PR #18) remains fork-only. + +## Refs + +Fork PRs #15, #17, #18; upstream #160, #161, #162. The upstream review ledger rows for +these live in `docs-ai/017-upstream-sync-process/upstream-ledger.md`. + +## Current state + +Parallel loading and direct `wt` execution are shared code with upstream and evolve through +normal upstream syncs. The snapshot cache is fork-maintained code that must be watched for +conflicts during upstream syncs; the ledger's "Pending upstream (#162)" status is stale — +the upstream PR was closed without merging. diff --git a/docs-ai/007-ghostty-embedding-integration/000-plan.md b/docs-ai/007-ghostty-embedding-integration/000-plan.md new file mode 100644 index 00000000..4cc39383 --- /dev/null +++ b/docs-ai/007-ghostty-embedding-integration/000-plan.md @@ -0,0 +1,105 @@ +# 007 — Ghostty Embedding Integration: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-21 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #26, #27, #29, #31, #32, #33 (initial wave); #237, #242, #352 (theme); #286, #348, #374 (text/key safety) | +| **Sources** | PR descriptions; upstream review ledger entry 2026-05-09 "Ghostty fork patch" (see `docs-ai/017-upstream-sync-process/upstream-ledger.md`); [ghostty-fork-sync.md](ghostty-fork-sync.md) (living runbook in this folder) | +| **Related** | [012-keybinding-system](../012-keybinding-system/000-plan.md) (key routing), [030-agent-status-detection](../030-agent-status-detection/000-plan.md) (`ghostty_surface_pid` fork patch), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md) | + +## Background + +Prowl embeds GhosttyKit as a C library: one `ghostty_app_t` (owned by `GhosttyRuntime`) +hosting many independent `ghostty_surface_t` sessions (each wrapped by a +`GhosttySurfaceBridge` + `GhosttySurfaceView`). Ghostty does not perform window/tab/UI +operations itself — it emits *runtime actions* (`ghostty_action_s`) through an app-level +callback and expects the embedder to implement them. A stock Ghostty macOS app implements +the full action set; early Prowl implemented only a handful (new tab, close tab, goto tab, +title updates), so many user keybindings and Ghostty command-palette entries were silent +no-ops, while others (e.g. `check_for_updates`) duplicated native Prowl features. + +Fork issues #21–#24 scoped the gap. For each Ghostty action the decision is one of: + +1. **Implement natively** at the right level (app-wide vs per-surface). +2. **Route into Prowl's feature layer** (TCA) when the action has app semantics that + Ghostty cannot know about (quit confirmation, native Sparkle updater). +3. **Filter out** actions that make no sense in Prowl's single-window architecture, so the + Ghostty command palette does not advertise dead entries. + +Two later waves belong to the same integration frame and are kept as amendments: making +Ghostty's theme follow Prowl's appearance mode, and hardening the C-ABI text/key-event +boundary (a real memory leak plus two classes of undefined text decoding). + +## Goals + +- Honor user Ghostty keybindings and command-palette commands wherever the action has a + sensible meaning inside Prowl (title prompts, open config, fullscreen, maximize, + background opacity, quit, close window). +- Route actions with app-level semantics through Prowl's own flow rather than bypassing it + (quit must hit confirm-before-quit; updates must use Sparkle). +- Hide unsupported/duplicate Ghostty actions from the in-terminal command palette. +- (Amendment 002) Terminal colors follow the app's Light/Dark appearance without ever + mutating the user's Ghostty config file. +- (Amendment 003) No memory leaks or malformed strings across the `ghostty_text_s` / + `NSEvent` boundary. + +**Non-goals**: multi-window Ghostty semantics (`new_window`, `goto_window`, +`close_all_windows`), window-decoration toggling (Prowl draws its own chrome), Ghostty's +inspector/GTK debug tooling, and key *binding* resolution itself (that is +[012-keybinding-system](../012-keybinding-system/000-plan.md)). + +## Design / Approach + +Action routing is layered, mirroring Ghostty's own target model: + +- **App level** — `GhosttyRuntime` intercepts actions before any surface bridge: + `GHOSTTY_ACTION_OPEN_CONFIG` (for `GHOSTTY_TARGET_APP`) resolves the config path via + `ghostty_config_open_path()` and opens it; `GHOSTTY_ACTION_QUIT` is forwarded to the app + store; `GHOSTTY_ACTION_CLOSE_WINDOW` closes the window owning the originating surface. +- **Surface level** — `GhosttySurfaceBridge.handleAppAction` implements + `toggle_fullscreen` (native `NSWindow.toggleFullScreen`, regardless of Ghostty's + native/non-native mode parameter), `toggle_maximize` (`NSWindow.zoom`), and + `toggle_background_opacity` (only effective when the user configured + `background-opacity < 1`; skipped in fullscreen, matching Ghostty). +- **Callback pattern** — surface actions that need UI (title prompts) follow the existing + bridge-callback style (`onTitleChange`, `onCloseRequest`, …): the bridge exposes + `onPromptTitle`, and `WorktreeTerminalState` presents the `NSAlert` sheet and applies + the result through `TerminalTabManager` title override/lock methods. +- **Palette filtering** — a single `Set<String>` of Ghostty action keys + (`filteredGhosttyActionKeys`) is consulted when building Ghostty-backed command-palette + items, hiding native duplicates and architecturally unsupported actions. + +## Alternatives & decisions + +- **Quit through TCA, not `NSApp.terminate` directly**: #31 first landed the direct + AppKit call, then (still inside #31) rerouted `GHOSTTY_ACTION_QUIT` through + `AppFeature.requestQuit` so Ghostty-initiated quit gets the same confirm-before-quit + behavior as the menu path. +- **Filter instead of implement** for `new_window` / `goto_window` / `close_all_windows` / + `toggle_tab_overview` / `toggle_window_decorations` / `inspector` / + `show_gtk_inspector` / `show_on_screen_keyboard`: Prowl is a single-window app with its + own chrome and tab bar; implementing these would fight the architecture. Explicitly + recorded in #27/#32/#33. +- **`check_for_updates` filtered, not bridged**: Prowl's Sparkle updater is the native + implementation ([021-sparkle-update-ux](../021-sparkle-update-ux/000-plan.md) territory); + the Ghostty palette entry would have been a no-op duplicate (#29). +- **Always native fullscreen**: Ghostty's fullscreen-mode parameter (native vs + non-native) is accepted but ignored; Prowl always uses macOS native fullscreen (#27). +- **Runtime-only theme override, never config mutation** (amendment 002): the fallback is + applied by loading a generated override file on top of the user's config in-process; + the user's Ghostty config file is never written. +- **Carry fork patches on `release/v<tag>-patched`** (amendment 003): the text-free ABI + fix was backported onto `onevcat/ghostty` `release/v1.3.1-patched` instead of waiting + for an upstream tag; the branch model, patch list, and upgrade procedure are maintained + in [ghostty-fork-sync.md](ghostty-fork-sync.md). + +## Amendments + +- Updated 2026-05-26: theme/appearance sync — single-theme runtime fallback (#237), + initial color-scheme sync (#242), explicit dual themes + no-theme default (#352) — see + [002-theme-appearance-sync.md](002-theme-appearance-sync.md) +- Updated 2026-05-30: text & key-event safety — text-free ABI fork backport (#286), + explicit-length text decoding (#348), no text reads from modifier key events (#374) — + see [003-text-and-key-event-safety.md](003-text-and-key-event-safety.md) diff --git a/docs-ai/007-ghostty-embedding-integration/001-action.md b/docs-ai/007-ghostty-embedding-integration/001-action.md new file mode 100644 index 00000000..1cc1ff9a --- /dev/null +++ b/docs-ai/007-ghostty-embedding-integration/001-action.md @@ -0,0 +1,72 @@ +# 007 — Ghostty Embedding Integration: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-21 | `prompt_surface_title` / `prompt_tab_title` → `NSAlert` rename dialogs (tab titles get `overrideTitle`/`clearTitleOverride` lock semantics on `TerminalTabManager`); `open_config` opens the resolved Ghostty config path | PR #26 | +| 2026-03-21 | `toggle_fullscreen` (native), `toggle_maximize` (`NSWindow.zoom`), `toggle_background_opacity` implemented in `GhosttySurfaceBridge.handleAppAction`; `toggle_window_decorations` explicitly excluded | PR #27 | +| 2026-03-21 | Filter `check_for_updates` from Ghostty palette items (native Sparkle updater is the real implementation) | PR #29 | +| 2026-03-21 | `quit` + `close_window` routed at `GhosttyRuntime` level; within the same PR, quit rerouted from `NSApp.terminate` to TCA `AppFeature.requestQuit` for confirm-before-quit | PR #31 | +| 2026-03-21 | Filter unsupported actions from palette: `new_window`, `close_all_windows`, `goto_window`, `toggle_tab_overview`, `inspector`, `show_gtk_inspector`, `show_on_screen_keyboard` | PR #32 | +| 2026-03-21 | Add `toggle_window_decorations` to the filter set (closes fork issue #21 together with #26/#27/#31) | PR #33 | +| 2026-04-24 → 2026-05-26 | Theme/appearance sync wave (#237, #242, #352) — see [002-theme-appearance-sync.md](002-theme-appearance-sync.md) | PRs #237, #242, #352 | +| 2026-05-09 | `onevcat/ghostty` fork branch `release/v1.3.1-patched` created (first patch: `ghostty_surface_pid`, for [030-agent-status-detection](../030-agent-status-detection/000-plan.md)); becomes the carrier for this entry's later ABI backport | ledger 2026-05-09 | +| 2026-05-13 → 2026-05-30 | Text & key-event safety wave (#286, #348, #374) — see [003-text-and-key-event-safety.md](003-text-and-key-event-safety.md) | PRs #286, #348, #374 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift` — app-level action + interception in `handleAction`: `GHOSTTY_ACTION_OPEN_CONFIG` (target `GHOSTTY_TARGET_APP`) + → `openGhosttyConfig()` (path from `ghostty_config_open_path()`, opened via + `/usr/bin/open -t`); `GHOSTTY_ACTION_QUIT` → `runtime.onQuit?()`; + `GHOSTTY_ACTION_CLOSE_WINDOW` → `closeWindow(target:)` closing the originating surface's + window (app-target is a no-op). Everything else falls through to the surface bridge. +- `supacode/App/supacodeApp.swift` wires `runtime.onQuit = { appStore?.send(.requestQuit) }`; + `requestQuit` lives in `supacode/Features/App/Reducer/AppFeature.swift`. +- `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — `handleAppAction` + implements `GHOSTTY_ACTION_TOGGLE_FULLSCREEN` / `TOGGLE_MAXIMIZE` / + `TOGGLE_BACKGROUND_OPACITY` (the latter via `GhosttySurfaceView.toggleBackgroundOpacity()`); + `GHOSTTY_ACTION_PROMPT_TITLE` invokes the `onPromptTitle` callback. +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — + `handlePromptTitle(_:tabId:)` maps **both** `GHOSTTY_PROMPT_TITLE_SURFACE` and + `GHOSTTY_PROMPT_TITLE_TAB` to the tab-title prompt (`promptTabTitle`), with an inline + comment noting Prowl's single-window model and suggesting the surface variant could be + dropped. +- `supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift` — + `filteredGhosttyActionKeys` (9 keys: `check_for_updates`, `new_window`, + `close_all_windows`, `goto_window`, `toggle_tab_overview`, `toggle_window_decorations`, + `inspector`, `show_gtk_inspector`, `show_on_screen_keyboard`), applied by + `ghosttyCommandItems(_:)`. The palette itself was later rebuilt + ([031-command-palette-architecture](../031-command-palette-architecture/000-plan.md)); + the filter survived the rebuild. +- Theme/appearance and text-safety state is detailed in the two amendment files; key + files: `supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift`, + `GhosttyRuntimeSupport.swift`, `supacode/App/GhosttyColorSchemeSyncView.swift`, + `GhosttySurfaceView.swift` (`GhosttyEventText`, `stringFromGhosttyText`), + `GhosttySurfaceView+EventTranslation.swift`, `MirroredTerminalKey.swift`. +- `ThirdParty/ghostty` submodule sits at `48365577c` on `release/v1.3.1-patched` + (v1.3.1 + 4 fork patches); branch model and upgrade procedure: + [ghostty-fork-sync.md](ghostty-fork-sync.md). + +## Deviations from plan + +- #26 shipped an `onOpenConfig` bridge callback; today `open_config` is handled entirely + at the `GhosttyRuntime` level and the bridge's `GHOSTTY_ACTION_OPEN_CONFIG` case is a + documented no-op ("Handled at app level"). The bridge callback no longer exists. +- #26 described distinct surface-title vs tab-title prompt flows; the current code + collapses both prompt variants into the tab-title prompt (see above). +- #31's PR body describes `NSApp.terminate` routing, but its final commit + (`4732780f`, "Route quit action through TCA requestQuit for confirm-before-quit") + already replaced that with the TCA path — the body was not updated. +- #32 and #33 carry near-identical descriptions; #33's actual diff only added + `toggle_window_decorations` to the filter set. + +## Open questions + +- `GHOSTTY_PROMPT_TITLE_SURFACE` support is marked in-code as a candidate for removal + ("Consider removing GHOSTTY_PROMPT_TITLE_SURFACE support entirely") but the decision was + never made; both variants still funnel into the tab prompt. +- The text-free-ABI fork patch (#286) is documented as droppable once the submodule + reaches an upstream tag containing upstream commit `4803d58`; as of 2026-07-12 the + submodule is still on v1.3.1-patched, so the patch remains load-bearing. diff --git a/docs-ai/007-ghostty-embedding-integration/002-theme-appearance-sync.md b/docs-ai/007-ghostty-embedding-integration/002-theme-appearance-sync.md new file mode 100644 index 00000000..dde47917 --- /dev/null +++ b/docs-ai/007-ghostty-embedding-integration/002-theme-appearance-sync.md @@ -0,0 +1,54 @@ +# 007 — Amendment: Theme / Appearance Sync (2026-04-24 → 2026-05-26) + +## Context + +Prowl has its own appearance setting (System / Light / Dark) while Ghostty renders with +the user's Ghostty theme. Two mismatches surfaced (fork issue #223, later #351): + +1. A user with a **single** Ghostty theme (e.g. a dark theme) got a dark terminal inside + a Light-mode Prowl window — Ghostty only adapts when the user configured a + `light:X,dark:Y` pair. The same applies with **no** theme at all: Ghostty's no-theme + default is a fixed dark background (`#282C34`) that ignores appearance entirely. +2. During startup, restored terminal surfaces could be created **before** SwiftUI + propagated `.preferredColorScheme`, so Ghostty picked the system Light/Dark branch + instead of the app-selected one. + +Constraint carried through all three PRs: never mutate the user's Ghostty config file; +any adaptation must be runtime-only and reversible. + +## Change + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-24 | Single-theme mismatch fallback: probe `ghostty +show-config` for `theme`/`background`, classify the background tone, and when a single theme's tone contradicts the app appearance, apply a runtime-only override in Ghostty's dual form (`theme = light:…,dark:…`); cleared as soon as tones re-align; parser tests added | PR #237 | +| 2026-04-27 | Initial color-scheme sync: `GhosttyRuntime(initialColorScheme:)` seeds the persisted appearance mode before any restored surface exists; `GhosttyColorSchemeSyncView` anchors to the explicit Light/Dark preference and only follows the environment in System mode | PR #242 | +| 2026-05-26 | Respect explicit same-name dual themes (`theme = light:X,dark:X` — collapsed to a single theme by `+show-config`, so the theme mode is re-derived from the **raw** user config) and fold the no-theme case into the fallback via `GhosttyThemeMode.allowsMismatchFallback`; `.dual` is always respected | PR #352 | + +The override is applied by writing the fallback line to a temp file +(`prowl-ghostty-theme-overrides.conf`) and rebuilding the Ghostty config with that file +loaded last — the same mechanism used for Prowl's keybind overrides. + +## Refs + +- PRs #237, #242, #352; fork issues #223, #351. + +## Current state (as of 2026-07-12) + +- `supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift` — + `reconcileThemeFallback(for:)` (off-main probe, short-circuited under test), + `applyResolvedThemeFallback`, `setThemeFallbackOverride`, + `applyRuntimeOverridesIfNeeded` (temp-file override loading), raw-config re-derivation + (`rawUserThemeMode`, `preferredGhosttyConfigURL` mirroring Ghostty's macOS config + selection; `config-file` includes are not resolved — documented limitation). +- `supacode/Infrastructure/Ghostty/GhosttyRuntimeSupport.swift` — `GhosttyThemeMode` + (`none`/`single`/`dual`, `allowsMismatchFallback`), `GhosttyTerminalTone`, + `GhosttyUserConfigSnapshot.parse(showConfigOutput:)`, `rawThemeSpec(fromConfig:)` + (last-wins, comment-aware), `classifyBackgroundTone` (luminance-only — #237's PR body + describes a strict saturation gate, but it was already dropped within #237's own review + cycle because tinted dark themes like Dracula/Nord were misclassified as `unknown`). +- `supacode/App/GhosttyColorSchemeSyncView.swift` + `GhosttyRuntime.init(initialColorScheme:)` + (seeded from persisted settings in `supacode/App/supacodeApp.swift`). +- Reload path: `GhosttyRuntime+Callbacks.swift` re-runs `reconcileThemeFallback` on + `GHOSTTY_ACTION_CONFIG_CHANGE`, so editing the Ghostty config re-evaluates the fallback. +- Tests: `supacodeTests/GhosttyUserConfigSnapshotTests.swift`, + `supacodeTests/GhosttyRuntimeColorSchemeTests.swift`. diff --git a/docs-ai/007-ghostty-embedding-integration/003-text-and-key-event-safety.md b/docs-ai/007-ghostty-embedding-integration/003-text-and-key-event-safety.md new file mode 100644 index 00000000..4b644883 --- /dev/null +++ b/docs-ai/007-ghostty-embedding-integration/003-text-and-key-event-safety.md @@ -0,0 +1,55 @@ +# 007 — Amendment: Text & Key-Event Safety (2026-05-13 → 2026-05-30) + +## Context + +Three independent defects at the GhosttyKit C-ABI / AppKit event boundary: + +1. **Memory leak on text reads** — an Instruments trace showed leaked Zig + `heap.CAllocator.alloc` buffers on the viewport-read path. Root cause is upstream + `ghostty-org/ghostty#12020`: the public header declares + `ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*)`, but the Zig export + accepted only `*Text`, so the surface pointer was interpreted as the text pointer and + the real allocation was never freed. Upstream fixed it in `ghostty-org/ghostty#12025`, + but no tagged release contained the fix yet. +2. **NUL-truncated decoding** — Prowl decoded `ghostty_text_s` buffers as NUL-terminated C + strings even though Ghostty returns an explicit `text_len`; terminal content containing + an interior NUL byte would be silently truncated. +3. **Exceptions/garbage from modifier events** — `NSEvent.characters` throws on non-key + events, and Ghostty key-event construction could touch it for `.flagsChanged` + (modifier-only) events. + +## Change + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-13 | Backport upstream `ghostty-org/ghostty#12025` onto `onevcat/ghostty` `release/v1.3.1-patched`; advance the submodule to `48365577c`; keep the upstream-compatible two-argument `ghostty_surface_free_text` call shape; document the split toolchain (GhosttyKit with Xcode 26.3, app with Xcode 26.4) | PR #286 | +| 2026-05-25 | Decode all `ghostty_text_s` reads (selection, viewport, accessibility, Quick Look, Services) through a shared bounded decoder using explicit `text_len`; interior-NUL regression tests | PR #348 | +| 2026-05-30 | Guard key text extraction: `.flagsChanged` events stay textless; only `keyDown`/`keyUp` read `NSEvent.characters`; regression tests for both | PR #374 | + +## Refs + +- PRs #286, #348, #374; upstream `ghostty-org/ghostty#12020` / `#12025`. +- Fork patch lifecycle: [ghostty-fork-sync.md](ghostty-fork-sync.md) — the #286 patch is + explicitly marked droppable once the submodule reaches an upstream tag containing + upstream commit `4803d58`. + +## Current state (as of 2026-07-12) + +- `ThirdParty/ghostty` is at `48365577c` (`v1.3.1` + 4 patches; the text-free ABI fix is + the tip commit). Every Swift call site pairs text reads with + `ghostty_surface_free_text(surface, &text)` — see the `GhosttySurfaceView+Services` / + `+Mouse` / `+TextInput` / `+Accessibility` extensions under + `supacode/Infrastructure/Ghostty/`. +- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` — + `stringFromGhosttyText(pointer:length:)` (bounded, UTF-8, empty on nil/0) and + `string(from: ghostty_text_s)` using `text_len`; `GhosttySurfaceBridge.swift` has + matching private length-taking `string(from:length:)` helpers for action payloads. +- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` — `GhosttyEventText.characters(for:)` + returns `nil` unless the event is `keyDown`/`keyUp` (also strips control-modified and + function-key private-use characters); used by + `GhosttySurfaceView+EventTranslation.swift`, whose `translationState` likewise skips + `characters`-family APIs for modifier-only events. `MirroredTerminalKey.init?(event:)` + only accepts `.keyDown` events. +- Tests: `supacodeTests/GhosttySurfaceViewTests.swift` (interior-NUL decoding), + `supacodeTests/MirroredTerminalKeyTests.swift` (`.flagsChanged` yields no text; plain + keyDown yields its character). diff --git a/docs-ai/008-terminal-notifications/000-plan.md b/docs-ai/008-terminal-notifications/000-plan.md new file mode 100644 index 00000000..72b83a9e --- /dev/null +++ b/docs-ai/008-terminal-notifications/000-plan.md @@ -0,0 +1,99 @@ +# 008 — Terminal Notifications: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-22 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #34, #35, #36, #37 (initial wave); #257, #340, #360, #361, #545, #546 (follow-up waves) | +| **Sources** | PR descriptions; fork-only rows and upstream-port decisions in the upstream review ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`); commits `182e165a`…`d7bb4b68`, `26968c1f`, `2db9ae5e` | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), `docs/components/notifications.md` | + +## Background + +Prowl's whole point is running many coding agents in parallel, which means the user is +usually *not* looking at the pane where something interesting just happened. The app +already had a notification inbox per worktree (fed by Ghostty desktop-notification +callbacks) with a toolbar bell, a sidebar indicator, Canvas card dots, and optional macOS +system notifications. Three gaps remained as of 2026-03-22: + +1. **Long-running commands gave no signal.** A `make build` or test run finishing in a + background tab was invisible unless the tool itself emitted a desktop notification. +2. **The Canvas dot was per-worktree**, so a notification in one tab lit up every card of + that worktree, and the 6×6pt dot was easy to miss when scanning many cards. +3. **Read-state had holes**: a notification arriving on the already-focused Canvas card + could not be dismissed at all, and interactive tools (e.g. typing `/exit` in Claude + Code) produced noisy "command finished" notifications for exits the user caused. + +## Goals + +- Notify when a long-running command finishes, with a user-configurable duration + threshold and an enable/disable toggle. +- Track unseen notifications per tab, not per worktree, so Canvas cards light up + individually. +- Make the Canvas indicator visible at a glance (full title-bar highlight instead of a + dot). +- Clear notifications on any key input to the focused surface; suppress command-finished + notifications when the user was just typing in that surface. + +**Non-goals** (at anchor time): system-notification routing changes, Dock integration, +and notification sounds beyond the existing single chime — these arrived in later waves +(see Amendments). + +## Design / Approach + +**Command-finished detection** rides on Ghostty's OSC 133 shell integration: the +`COMMAND_FINISHED` action surfaces as a new `onCommandFinished` callback on +`GhosttySurfaceBridge`, carrying duration and exit code. `WorktreeTerminalState` applies +the filters: + +- feature enabled and `duration >= threshold` (default 10 s); +- skip user-initiated termination (exit codes 130/SIGINT and 143/SIGTERM); +- skip if the user typed in that surface within a recent-interaction window + (`lastKeyInputTimeBySurface`, added in commit `2db9ae5e` right after the PR wave). + +Anything that passes goes through the existing `appendNotification` path, so the bell, +sidebar indicator, Canvas highlight, and system notifications all work without new +plumbing. Notifications are auto-marked read when the producing surface is both selected +and focused. + +**Settings**: two new `GlobalSettings` fields (`commandFinishedNotificationEnabled`, +`commandFinishedNotificationThreshold`) with a "Command Finished" section in +`NotificationsSettingsView`, propagated to the terminal layer via `TerminalClient` → +`WorktreeTerminalManager` (the standard reducer→terminal command path). + +**Per-tab unseen state**: `WorktreeTerminalState.hasUnseenNotification(for:)` filters the +notification list by the surfaces belonging to one tab; `CanvasView` queries per tab +instead of reading the worktree-level flag. + +**Visibility**: the Canvas card title bar gets a full-width orange tint overlay when the +tab has unseen notifications (initially `Color.orange.opacity(0.3)` over the `.bar` +material). + +**Mark-read on input**: `GhosttySurfaceView` gains an `onKeyInput` callback on `keyDown`, +wired to `markNotificationsRead(forSurfaceID:)` — any keystroke into a focused surface +clears its unseen notifications. The same timestamp feeds the command-finished +suppression window. + +## Alternatives & decisions + +- **Reuse the existing notification inbox instead of a parallel "command finished" + channel** — deliberate; one `appendNotification` funnel keeps every indicator surface + consistent and later made follow-up features (sound gating, mute-when-viewed) one-line + gates (see 004 amendment). +- **Exit-code filtering over "always notify"** — SIGINT/SIGTERM exits are treated as + user-initiated and skipped, accepting that a genuinely failed long command killed by + signal is silent; the recent-input window covers the interactive-tool case (`/exit`, + `quit`) that exit codes alone cannot. +- **Per-tab rather than per-surface Canvas indication** — Canvas cards represent tabs, so + tab granularity matches what the user can click; per-surface (split) indicators came + later via the upstream #266 port (002 amendment). + +## Amendments + +- Updated 2026-05-08: notification jump (⌘⌥U) + per-tab/per-split unread indicators, + upstream port — see [002-notification-jump-and-indicators.md](002-notification-jump-and-indicators.md) +- Updated 2026-05-27: toolbar-item visibility + Dock badge/bounce options, and the stuck + bell/Dock-badge fix — see [003-toolbar-dock-options-and-stuck-indicator.md](003-toolbar-dock-options-and-stuck-indicator.md) +- Updated 2026-07-08: notification sound picker + mute-for-viewed-surface, upstream ports + with fork adaptations — see [004-sound-picker-and-viewed-surface-mute.md](004-sound-picker-and-viewed-surface-mute.md) diff --git a/docs-ai/008-terminal-notifications/001-action.md b/docs-ai/008-terminal-notifications/001-action.md new file mode 100644 index 00000000..a98817e9 --- /dev/null +++ b/docs-ai/008-terminal-notifications/001-action.md @@ -0,0 +1,65 @@ +# 008 — Terminal Notifications: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-22 | Canvas notification dot made per-tab via `hasUnseenNotification(for:)` | PR #34 | +| 2026-03-22 | Command-finished notification (OSC 133), threshold setting (default 10 s), SIGINT/SIGTERM filtering | PR #35 (`182e165a`) | +| 2026-03-22 | Canvas card title bar fully highlighted orange for unseen notifications, replacing the dot | PR #36 (`d7bb4b68`) | +| 2026-03-22 | Mark notifications read on key input to the focused surface (`onKeyInput` → `markNotificationsRead`) | PR #37 (`26968c1f`) | +| 2026-03-22 | Suppress command-finished notification when the user typed in that surface recently | commit `2db9ae5e` | +| 2026-05-08 | Jump to Latest Unread (⌘⌥U), surface IDs threaded through notifications, unread dots on tabs/splits (upstream #266 port) | PR #257 → [002](002-notification-jump-and-indicators.md) | +| 2026-05-27 | Toolbar item visibility options; Dock notification dot + bounce modes (community PR + refinement) | PR #340 + #361 → [003](003-toolbar-dock-options-and-stuck-indicator.md) | +| 2026-05-27 | Stuck bell/Dock-badge fix: prune a surface's notifications on teardown via `forgetSurface(_:)` | PR #360 → [003](003-toolbar-dock-options-and-stuck-indicator.md) | +| 2026-07-08 | Customizable notification sound picker (upstream #511 port; fork keeps its classic chime as default) | PR #545 → [004](004-sound-picker-and-viewed-surface-mute.md) | +| 2026-07-08 | Mute banner/sound/bounce for the surface currently being viewed (upstream #562 port; `isViewed` threading) | PR #546 → [004](004-sound-picker-and-viewed-surface-mute.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift` — the + notification core: `handleCommandFinished`-style filtering (enabled flag, `durationSeconds >= + commandFinishedNotificationThreshold`, exit codes 130/143 skipped, `lastKeyInputTimeBySurface` + + `recentInteractionWindow` suppression), `appendNotification(title:body:surfaceId:)` with + auto-read when the surface is selected and focused, and notification pruning helpers. +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — surface teardown + (`forgetSurface`), key-input timestamping, and `isViewedSurface(_:)` (see 004). +- `supacode/Features/Settings/Models/GlobalSettings.swift` — `commandFinishedNotificationEnabled` + (default `true`), `commandFinishedNotificationThreshold` (default `10`), plus the later-wave + fields: `notificationSound`, `muteNotificationsForActiveSurface`, `showRunButtonInToolbar`, + `showDefaultEditorInToolbar`, `dockBounceMode`, `showNotificationDotOnDock`. +- `supacode/Features/Settings/Views/NotificationsSettingsView.swift` — three sections: + "Notifications" (bell, sound picker, move-to-top, mute-viewed, bounce), "System" (banners, + Dock badge with disabled-state caption), "Command Finished" (toggle + threshold field). +- `supacode/Features/Canvas/Views/CanvasCardView.swift` — title-bar overlay + `Color.orange.opacity(0.55)` when `hasUnseenNotification`; a code comment documents that the + notification tint deliberately wins over the repo color tint. +- `supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift` — reducer-side handling of + `TerminalClient.Event.notificationReceived` (banner/sound/Dock effects, mute gate). +- Toolbar bell: `supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift` and + `supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift`. +- Tests exist for each wave: `supacodeTests/CommandFinishedNotificationTests.swift`, + `ToolbarNotificationGroupingTests.swift`, `WorktreeTerminalNotificationPruneTests.swift`, + `AppFeatureSystemNotificationTests.swift`, `AppFeatureDockTests.swift`, + `NotificationSoundTests.swift`. +- User-facing behavior is documented in `docs/components/notifications.md`. + +## Deviations from plan + +- The Canvas title-bar tint shipped at `opacity(0.3)` (PR #36) but is `opacity(0.55)` today — + raised during later Canvas appearance work so the orange stays recognizable over per-repo + color tints (rationale kept as an inline comment in `CanvasCardView.swift`). +- PR #37's mark-read-on-input landed as planned, but the companion suppression (recent-input + window for command-finished) went in as a direct commit (`2db9ae5e`) the same evening rather + than through a PR. +- The plan's initial-wave scope otherwise matches; everything beyond it arrived as the three + amendment waves. + +## Open questions + +- PR #35's test plan says a `sleep 15` finishing while the tab stays focused produces no + *unread* notification (auto-marked read); in current code an in-window keystroke suppresses + the notification entirely while a hands-off focused wait still appends a read notification to + the inbox. This looks intentional (inbox keeps history) but the two mechanisms overlap. +- Exit code filtering treats every 130/143 as user-initiated; a long job killed externally by + SIGTERM is silently unnotified. No evidence this was ever revisited. diff --git a/docs-ai/008-terminal-notifications/002-notification-jump-and-indicators.md b/docs-ai/008-terminal-notifications/002-notification-jump-and-indicators.md new file mode 100644 index 00000000..fca55bd2 --- /dev/null +++ b/docs-ai/008-terminal-notifications/002-notification-jump-and-indicators.md @@ -0,0 +1,35 @@ +# 008 Amendment — Notification Jump & Surface Indicators (2026-05-08) + +## Context + +Part of the 2026-05-08 upstream review batch (post-v0.8.5): upstream `072ad1e7` +(supabitapp/supacode #266) added jump-to-unread notifications. The fork ported it as PR +#257, adapting it to the fork's notification model from the initial wave. Until then a +notification told you *that* something happened but offered no fast way to get *there*, +and unread state was only visible on Canvas cards and the worktree row — not on the tab +bar or on individual split panes. + +## Change + +- **Jump to Latest Unread** (⌘⌥U): focuses the newest unread notification's surface; + exposed as a menu command and in the command palette. +- **Source surface IDs threaded through** terminal and system notifications, enabling + tap-to-focus on macOS notification banners. +- **Unread dots on terminal tabs and split surfaces**, extending the per-tab model of PR + #34 down to per-surface granularity. +- Tests added for newest-focusable-notification lookup and the jump/system-notification + paths (`WorktreeTerminalManagerTests`, `AppFeatureJumpToLatestUnreadTests`, + `AppFeatureSystemNotificationTests`, `AppShortcutsTests`). + +## Refs + +PR #257 (merged 2026-05-08), port of upstream #266 (`072ad1e7`). Batch decision recorded +in the upstream review ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`). + +## Current state + +`jumpToLatestUnread` is defined in `supacode/App/AppShortcuts.swift` (⌘⌥U) and appears in +`supacode/Commands/WorktreeCommands.swift`, the command palette, and +`supacode/Features/App/Reducer/AppFeature.swift`. Unread indicators render in +`supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift` and the shelf/sidebar +views that query `hasUnseenNotification`. diff --git a/docs-ai/008-terminal-notifications/003-toolbar-dock-options-and-stuck-indicator.md b/docs-ai/008-terminal-notifications/003-toolbar-dock-options-and-stuck-indicator.md new file mode 100644 index 00000000..751d29f9 --- /dev/null +++ b/docs-ai/008-terminal-notifications/003-toolbar-dock-options-and-stuck-indicator.md @@ -0,0 +1,54 @@ +# 008 Amendment — Toolbar/Dock Options & Stuck-Indicator Fix (2026-05-27) + +## Context + +Two related landings on 2026-05-27. First, community contributor @abhi21git proposed PR +#340 (hide toolbar items; Dock notification dot; Dock bounce). It was merged together +with PR #361, which builds on it with correctness/architecture refinements. Second, +users could get a toolbar bell (and now Dock badge) **stuck lit** with only "Dismiss All" +clearing it — fixed in PR #360. + +## Change + +**Toolbar & Dock options (#340 + #361):** + +- Option-gated visibility for the Open-in-Editor toolbar item and the Run button (both + shown by default); a toolbar-grouping fix so hiding Run does not merge Custom Commands + into the neighboring group. +- Dock notification dot (badge) and Dock bounce on notification (off / once / + continuous, `DockBounceMode`). +- Refinements in #361: Dock side effects routed through an injected `DockClient` + (swift-dependencies) instead of direct `NSApp` calls in the reducer; badge actually + renders (unread worktree count + forced `dockTile.display()`); Notifications settings + split into app-controlled vs System sections, with the badge toggle disabled + captioned + when macOS notification permission or "Badge app icon" is off (re-checked on app focus); + friendlier permission-denied alert; `GlobalSettings.init(from:)` decode helpers extracted + into a `ToolbarAndDockSettings` struct to drop a SwiftLint disable. + +**Stuck indicator fix (#360):** notifications are keyed by producing surface and only +marked read when that surface gains focus or key input. Surface teardown (`removeTree` / +close handling) cleaned every per-surface dictionary *except* `notifications`, so an +unread notification from a closed tab/split had no surface left to clear it and the +bell/Dock badge stayed lit forever. Fix: consolidate per-surface teardown into +`forgetSurface(_:)`, drop that surface's notifications there (emitting the indicator +change), with a pure `prunedNotifications(from:removingSurfaceID:)` helper for unit +testing without a live Ghostty surface. + +## Refs + +PR #340 + PR #361 (merged 2026-05-27), PR #360 (merged 2026-05-27). + +## Current state + +- `supacode/Clients/Dock/DockClient.swift` — Dock badge/bounce client; + `dockBounceMode` (default `.off`) and `showNotificationDotOnDock` (default `false`) plus + `showRunButtonInToolbar` / `showDefaultEditorInToolbar` (default `true`) live in + `supacode/Features/Settings/Models/GlobalSettings.swift` (decoded via the private + `ToolbarAndDockSettings` struct). +- `supacode/Features/Settings/Views/NotificationsSettingsView.swift` — the app-controlled + vs "System" section split, including the disabled Dock-badge caption. +- `forgetSurface` / `prunedNotifications` in + `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` and + `supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift`; covered by + `supacodeTests/WorktreeTerminalNotificationPruneTests.swift` and + `supacodeTests/AppFeatureDockTests.swift`. diff --git a/docs-ai/008-terminal-notifications/004-sound-picker-and-viewed-surface-mute.md b/docs-ai/008-terminal-notifications/004-sound-picker-and-viewed-surface-mute.md new file mode 100644 index 00000000..247aa73b --- /dev/null +++ b/docs-ai/008-terminal-notifications/004-sound-picker-and-viewed-surface-mute.md @@ -0,0 +1,74 @@ +# 008 Amendment — Sound Picker & Viewed-Surface Mute (2026-07-08) + +## Context + +Two ports from the 2026-07-09 upstream review batch (post-v0.10.5), landed as a stacked +pair: upstream `ce03d3c3` (#511, customizable notification sound) → fork PR #545, and +upstream `f15420ce` (#562, mute notifications for the viewed surface) → fork PR #546. +Both were adapted to the fork's single-target settings architecture and its unified +notification funnel (all notification types — agent OSC desktop notifications and the +fork-only command-finished notifications — flow through one playback/effect point in +`AppFeature+TerminalEvents.swift`). + +## Change + +**Notification sound picker (#545):** replaces the boolean `notificationSoundEnabled` +with a `NotificationSound` enum — `never`, 14 `/System/Library/Sounds` system sounds, and +the bundled `supacodeClassic` (the fork's existing notification.wav, displayed as "Prowl +Classic"). Selecting a sound in settings previews it immediately (in-app path only; when +system banners are enabled the picker is disabled since the banner carries its own +sound). `NSSound` instances are cached per case (`@MainActor`). + +Fork adaptations (deliberate differences from upstream): + +- Default and legacy-`true` migration target is `.supacodeClassic`, **not** upstream's + `.hero` — existing fork users keep hearing the same chime after upgrading. +- Enum raw values are byte-identical to upstream (including the `supacodeClassic` + spelling) so future upstream sound commits cherry-pick with zero migration; only the + `displayName` says "Prowl Classic". +- Migration: legacy `notificationSoundEnabled == true` → default sound, `== false` → + `.never`; unknown raw values are isolated with `try?` and fall back to the default + without breaking decoding of sibling fields. + +**Mute for the viewed surface (#546):** new `muteNotificationsForActiveSurface` setting +(default on). A notification originating from the surface the user is actively viewing +skips the macOS banner, sound, and Dock bounce (sidebar move-to-top still runs; the +inbox unread flag is deliberately untouched — muting external effects is a separate +concept from read state). + +- "Viewed" = `WorktreeTerminalState.isViewedSurface(_:)`: selected worktree AND focused + pane AND window key AND window visible. +- Fork adaptation: `isViewed` is threaded through the event pipeline — + `onNotificationReceived` and `TerminalClient.Event.notificationReceived` widened to + carry `isViewed: Bool` — and **canvas-managed surfaces are always treated as + not-viewed** (`guard !isCanvasManaged`), because their stale window flags would + otherwise silently drop notifications. Unknown window state likewise counts as + not-viewed: the design errs toward notifying, never toward silent drops. +- The effect parameters were consolidated into a `TerminalNotificationPayload` struct. + +## Refs + +PR #545 and PR #546 (both merged 2026-07-08); upstream #511 (`ce03d3c3`) and #562 +(`f15420ce`). Batch context and the fork-adaptation note are recorded in the upstream +review ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`). + +## Current state + +- `supacode/Features/Settings/Models/NotificationSound.swift` — the enum (`never`, 14 + system cases, `supacodeClassic`) with a source mapping (`system(name:)` / + `bundled(resource:withExtension:)`). +- `supacode/Clients/Notifications/NotificationSoundClient.swift` — playback + per-case + `NSSound` cache. +- `supacode/Features/Settings/Models/GlobalSettings.swift` — `notificationSound` + (default `.supacodeClassic`, legacy-bool migration in `decodeNotificationSound`) and + `muteNotificationsForActiveSurface` (default `true`). +- `supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift` — + `TerminalNotificationPayload` and the mute gate + (`state.settings.muteNotificationsForActiveSurface && notification.isViewed`). +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — + `isViewedSurface(_:)` with the canvas-managed guard. +- Tests: `supacodeTests/NotificationSoundTests.swift` (raw-value contract, grouping, + decode), plus updated `AppFeatureSystemNotificationTests` / `AppFeatureDockTests` / + settings-persistence suites. +- Documented for users in `docs/components/notifications.md` and + `docs/reference/settings-fields.md`. diff --git a/docs-ai/009-terminal-surface-lifecycle/000-plan.md b/docs-ai/009-terminal-surface-lifecycle/000-plan.md new file mode 100644 index 00000000..011ac687 --- /dev/null +++ b/docs-ai/009-terminal-surface-lifecycle/000-plan.md @@ -0,0 +1,105 @@ +# 009 — Terminal Surface Lifecycle: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-23 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #42, #50, #132, #156, #198, #201, #203, #372 | +| **Sources** | `doc-onevcat/canvas-exit-terminal-blank-tracking.md` (absorbed; investigation closed 2026-04-29), PR descriptions | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md) | + +## Background + +Prowl keeps one `GhosttySurfaceView` (an `NSView` wrapping a `ghostty_surface_t`) alive per +terminal pane and reparents it between SwiftUI hosts: the normal tab view +(`WorktreeTerminalTabsView`) and Canvas cards (`CanvasView`) both host the *same* AppKit +view. Rendering cost is controlled by "occlusion": `ghostty_surface_set_occlusion` pauses +or resumes a surface's Metal render loop. + +Shortly after Canvas shipped (see +[005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md)), a persistent symptom +appeared: **exiting Canvas returned to the correct tab, with correct reducer/tab state, +but the terminal rendered blank** until the user switched away and back. The bug was +intermittent and timing-dependent, which turned this into a multi-week investigation +(2026-03-23 → 2026-04-29) rather than a single fix. + +## Goals + +- Terminal surfaces must render correctly after every Canvas enter/exit transition and + any other SwiftUI/AppKit reparenting. +- Occlusion state sent to Ghostty must match what the UI actually needs — no surface left + paused while visible, and (later wave) no surface left rendering while invisible. +- Exactly one live host owns a surface at a time; host changes must never leave the + surface detached from the view tree. + +**Non-goals**: changing the shared-surface architecture itself (one `ghostty_surface_t` +reparented between hosts was kept throughout), or Canvas interaction behavior (tracked in +[024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md)). + +## Investigation record (hypotheses in order) + +The root-cause understanding evolved across four hypotheses; each produced a fix that was +kept, because each addressed a real (if not always the primary) failure mode. + +1. **SwiftUI `onAppear`/`onDisappear` ordering race** (#42). In SwiftUI's if/else view + swap, the incoming view's `onAppear` fires *before* the outgoing view's `onDisappear`. + `deactivateCanvas()` (in `onDisappear`) occluded all surfaces *after* the tab view's + `syncFocus()` had already un-occluded them. Regression introduced by commit `ff4f7c6` + (clearing `selectedWorktreeID` when entering Canvas), which made the nil→ID transition + in `setSelectedWorktreeID` pass its same-ID guard with no compensating un-occlude. + Fix: stop occluding in `deactivateCanvas()`; move canvas-exit cleanup (occlude + non-selected worktrees) into `WorktreeTerminalManager.setSelectedWorktreeID`. + +2. **Occlusion sent while the renderer could not resume** (#50, #156). A + `setOcclusion(true)` call could land while the surface was detached from the view tree + during reparenting; Ghostty could not resume rendering, but the caller believed the + value was applied. Fix: give `GhosttySurfaceView` an `OcclusionState` cache tracking + `desired` vs `applied`; invalidate `applied` on every NSView attachment change + (`viewDidMoveToWindow` / `viewDidMoveToSuperview`) so the desired value is re-sent + after real reattachment; and (#156) defer *un*-occluding until the surface has both a + superview and a window, storing only `desired` in the meantime. + +3. **Still reproducible → instrument the critical path** (#132). With reducer state + verified correct, the fix added an aggressive surface-activity refresh on canvas exit + plus `[CanvasExit]` diagnostics across selection, tab-view appearance, and surface + reattachment, to catch the surviving repro in logs. + +4. **Host ownership loss during reparenting — the accepted root cause** (#201). Canvas + and terminal wrappers both host the same `GhosttySurfaceView`. A stale terminal + wrapper could re-adopt a surface still owned by the live Canvas host; when that stale + wrapper later deinitialized, AppKit removed the surface from the view tree again, + leaving the *active* host blank even though selection and tab state were correct. + Fix: terminal hosts only defensively reattach **orphaned** surfaces + (`surfaceView.superview == nil`) and never steal a surface from another live host. + +A related regression was fixed in the same frame: **Canvas split-pane rendering freeze** +(#203). After commit `979e8e2f` routed tree mutations through `syncFocusIfNeeded` → +`applySurfaceActivity`, entering Canvas removed `WorktreeTerminalTabsView`, whose +`WindowFocusObserverNSView` recorded stale `windowIsVisible=false`; any split-tree change +in Canvas then occluded every surface. Fix: an `isCanvasManaged` flag on +`WorktreeTerminalState` makes `syncFocusIfNeeded` skip `applySurfaceActivity` while Canvas +owns visibility, and new split panes are explicitly un-occluded while the flag is active. + +## Alternatives & decisions + +- **Adopt-orphans-only over ownership tracking**: rather than a central registry of which + host owns which surface, the rule is local and defensive — a terminal wrapper reattaches + a surface only if it is not attached anywhere. The tracking doc records the residual + risk: any future host of `GhosttySurfaceView` must follow the same rule. +- **Occlude eagerly, un-occlude lazily**: pausing rendering is always safe (applied + immediately, even with no view hierarchy — #198), while resuming is deferred until the + surface is genuinely attached. Asymmetry is deliberate. +- **Keep low-frequency diagnostics**: most investigation logging was removed at closure, + but `[CanvasExit] enteringCanvas / setSelectedWorktreeID / deferOcclusion / + hostReattach / hostReattachComplete` and `[TerminalWake]` summaries were kept as a + regression tripwire. +- **Investigation closed, not proven**: the tracking doc explicitly labels host ownership + loss as the "most likely failure mode"; closure was based on non-reproduction after + #201/#203, backed by host-ownership and occlusion unit tests. + +## Amendments + +- Updated 2026-05-29: surfaces alive when they should not be — CPU spin of restored + non-displayed surfaces (#198) and terminal surface leak on tab close (#372) — see + [002-surface-resource-waste.md](002-surface-resource-waste.md) diff --git a/docs-ai/009-terminal-surface-lifecycle/001-action.md b/docs-ai/009-terminal-surface-lifecycle/001-action.md new file mode 100644 index 00000000..d8097b3b --- /dev/null +++ b/docs-ai/009-terminal-surface-lifecycle/001-action.md @@ -0,0 +1,67 @@ +# 009 — Terminal Surface Lifecycle: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-23 | Fix blank surface when exiting Canvas via toggle shortcut: stop occluding in `deactivateCanvas()` (onAppear/onDisappear ordering race); occlude non-selected worktrees from `setSelectedWorktreeID` on canvas exit instead | PR #42 | +| 2026-03-24 | General occlusion cache (`OcclusionState`, desired vs applied); re-send current occlusion on `GhosttySurfaceView` reattachment, tied to real NSView lifecycle events; unit coverage | PR #50 | +| 2026-04-03 | Blank terminal still reproducible: force-refresh surface activity on canvas exit, invalidate per-surface occlusion caches before reapplying visibility/focus, add `[CanvasExit]` diagnostics | PR #132 | +| 2026-04-05 | Defer occlusion apply until the surface has both a superview and a window; keep desired value across canvas-exit reparenting so the real reattach triggers a fresh apply | PR #156 | +| 2026-04-11 | Stop restored, non-displayed surfaces from spinning CPU/GPU: occluding applies immediately even without a view hierarchy — see [002-surface-resource-waste.md](002-surface-resource-waste.md) | PR #198 | +| 2026-04-14 | Host ownership fix (accepted root cause): terminal wrapper reattaches only orphaned surfaces, never steals from a live host (e.g. Canvas); detach-intent diagnostics; host-ownership unit tests | PR #201 | +| 2026-04-16 | Fix Canvas split-pane rendering freeze: `isCanvasManaged` flag skips `applySurfaceActivity` while Canvas owns occlusion; new split panes explicitly un-occluded in Canvas | PR #203 | +| 2026-04-29 | Investigation closed in the tracking doc: no recurrence after #201/#203; most investigation logs removed, low-frequency `[CanvasExit]` / `[TerminalWake]` logs retained | `doc-onevcat/canvas-exit-terminal-blank-tracking.md` | +| 2026-05-29 | Fix terminal surface leak on tab close (fixes #370): stale SwiftUI renders no longer recreate surfaces for closed tabs; first tab uses Ghostty tab context — see [002-surface-resource-waste.md](002-surface-resource-waste.md) | PR #372 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` — `OcclusionState` struct + (`desired`/`applied`, `setDesired`, `prepareToApply`, `invalidateForAttachmentChange`). + `setOcclusion(_:)` pauses immediately (even with no surface/view hierarchy) but defers + resume until `isReadyToApplyOcclusion` (superview + window), logging + `[CanvasExit] deferOcclusion` when deferring. `viewDidMoveToWindow` / + `viewDidMoveToSuperview` call `handleAttachmentChange()`, which invalidates the applied + cache, asks the scroll wrapper to `ensureSurfaceAttached()` when orphaned, and reapplies + the desired value once attached. +- `supacode/Infrastructure/Ghostty/GhosttySurfaceScrollView.swift` — + `ensureSurfaceAttached(requiresLiveHost:)`: terminal-host-only (`hostKind == .terminal`), + adopts the surface only when `surfaceView.superview == nil` (comment: "Only adopt an + orphaned surface; never steal it from a live host such as Canvas"), with + `[CanvasExit] hostReattach` / `hostReattachComplete` logs. +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — + `setSelectedWorktreeID` handles the leaving-canvas transition (previous selection nil): + occludes all worktrees except the newly selected one; `[CanvasExit] enteringCanvas` / + `setSelectedWorktreeID` logs retained. +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — `isCanvasManaged` + flag; `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — + `syncFocusIfNeeded()` guards on `!isCanvasManaged`; `createSplitOnFocusedSurface` + un-occludes new panes when canvas-managed; `setAllSurfacesOccluded()` helper. +- `supacode/Features/Canvas/Views/CanvasView+Focus.swift` — `activateCanvas()` sets + `isCanvasManaged` and manages occlusion for card surfaces; `deactivateCanvas()` clears + the flag and deliberately does *not* occlude (comment documents the + onAppear-before-onDisappear rationale from #42). +- `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` — `[TerminalWake]` sleep/wake + summary log retained. +- Tests in `supacodeTests/GhosttySurfaceViewTests.swift`: the `OcclusionState` suite + (resend-after-attachment-change, deferred desired values, + `occlusionFalseAppliesImmediatelyWithoutViewAttachment`, + `occlusionCanRecoverWhenAttachmentCallbackIsMissedAfterReattachment`) and the host + ownership trio (`terminalHostReattachesSurfaceOnlyAfterItLeavesTheViewTree`, + `terminalHostDoesNotStealSurfaceFromCanvasHost`, + `canvasHostDoesNotStealDetachedSurfaceBack`) — all named in the tracking doc and present. + +## Deviations from plan + +- #132's "force-refresh terminal surface activity when exiting Canvas" + (`refreshSurfaceActivity`) no longer exists in the tree; the mechanism was superseded by + #156's deferred-apply and #203's `isCanvasManaged` ownership of visibility. Its + surviving contribution is the `[CanvasExit]` diagnostic trail. +- Most per-step investigation logging added during #132/#201 was removed at closure + (2026-04-29); only the low-frequency log set listed in the tracking doc remains. + +## Open questions + +- The root cause is the tracking doc's "most likely failure mode", established by + elimination and non-reproduction rather than a captured repro of the stale-wrapper + deinit; the entry reflects that confidence level. diff --git a/docs-ai/009-terminal-surface-lifecycle/002-surface-resource-waste.md b/docs-ai/009-terminal-surface-lifecycle/002-surface-resource-waste.md new file mode 100644 index 00000000..0248c6cb --- /dev/null +++ b/docs-ai/009-terminal-surface-lifecycle/002-surface-resource-waste.md @@ -0,0 +1,50 @@ +# 009.002 — Surfaces Alive When They Should Not Be (#198, #372) + +## Context + +The main investigation ([000-plan.md](000-plan.md)) chased surfaces that failed to render +when they *should*. Two further defects were the inverse: surfaces consuming resources — +or entire shell processes — while they should have been paused or gone. + +- **CPU spin of restored non-displayed surfaces** (2026-04-11). Session restore (see + [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md)) + creates surfaces that may never be attached to a window. The deferred-apply rule from + #156 deferred *all* occlusion changes until attachment, so `setOcclusion(false)` for a + never-attached restored surface was never delivered and its Metal render loop kept + spinning the CPU/GPU. +- **Terminal surface leak on tab close** (2026-05-29, fixes issue #370). A tab could + close while SwiftUI still rendered a stale selected tab id. `splitTree(for:)` treated + any missing tree as lazily creatable, so the stale render silently created a fresh + Ghostty surface — and a new shell process — for the already-closed tab, outside the + visible tab lifecycle. Separately, the first terminal tab was created with Ghostty's + *window* context even though Prowl embeds all terminal tabs in its own window/tab UI. + +## Change + +- #198: make the deferral asymmetric — occluding (pausing render) applies immediately, + even without a surface view hierarchy; only un-occluding waits for attachment. Added + `occlusionFalseAppliesImmediatelyWithoutViewAttachment` coverage. +- #372: `splitTree(for:)` now returns an empty `SplitTree` for tab ids no longer present + in `tabManager.tabs` instead of lazily creating a surface; the initial tab surface uses + `GHOSTTY_SURFACE_CONTEXT_TAB` like every subsequent tab. Regression coverage for the + normal close path and the Ghostty close-callback path. + +## Refs + +PR #198, PR #372 (fixes #370). + +## Current state + +- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` — `setOcclusion(_:)` has the + immediate-pause fast path in both the live-surface and testing branches ("Occluding + (pausing render) is always safe, even without a view hierarchy"). +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — + `splitTree(for:)` guards `tabManager.tabs.contains(where: { $0.id == tabId })` and its + default surface context is `GHOSTTY_SURFACE_CONTEXT_TAB`; + `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` `createTab` also uses + `GHOSTTY_SURFACE_CONTEXT_TAB`. +- Tests: `supacodeTests/WorktreeTerminalManagerTests.swift` + (`splitTreeDoesNotRecreateSurfaceForClosedTab`, + `ghosttyCloseRequestDoesNotRecreateSurfaceForClosedTab`) and + `supacodeTests/GhosttySurfaceViewTests.swift` + (`occlusionFalseAppliesImmediatelyWithoutViewAttachment`). diff --git a/docs-ai/010-plain-folder-support/000-plan.md b/docs-ai/010-plain-folder-support/000-plan.md new file mode 100644 index 00000000..ac695e93 --- /dev/null +++ b/docs-ai/010-plain-folder-support/000-plan.md @@ -0,0 +1,97 @@ +# 010 — Plain Folder Support: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-24 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #48, #80, #553 | +| **Sources** | `doc-onevcat/plans/2026-03-24-plain-folder-support-plan.md` (absorbed here; original removed in the docs-ai migration), PR #48/#80/#548/#553 descriptions, change-list 2026-04-20 review batch | +| **Related** | [034-worktree-watcher-correctness](../034-worktree-watcher-correctness/000-plan.md), [042-project-workspaces](../042-project-workspaces/000-plan.md), `docs/concepts.md`, `docs/components/repositories-and-worktrees.md` | + +## Background + +Prowl's sidebar was git-only: the Add Repository flow resolved every picked folder +through `gitClient.repoRoot` and rejected failures as invalid roots. There was no way to +open an arbitrary folder (a scratch directory, a non-git project) in Prowl's terminal +tabs. The selection model was also worktree-centric — nothing could be "selected" unless +it was a git worktree — which made non-git folders impossible to represent without hacks. + +## Goals + +- Add plain (non-git) folders through the existing Add Repository flow. +- Persist and restore mixed `.git` and `.plain` entries (including the snapshot cache). +- Make a plain folder a first-class selectable sidebar item with a usable terminal + detail view. +- Reuse non-git repository settings (open action, run script, custom commands) for + plain folders. +- Gate git-only behavior through shared capabilities instead of scattered + `kind == .git` checks. +- Keep mixed states coherent: git repos, plain folders, failed git loads. + +### Non-goals + +- Pull request support, branch operations, diff/line-change tracking, worktree + creation/archive/delete, and GitHub integration for plain folders. + +## Design / Approach + +Condensed from the original plan document. + +**Domain modeling.** Extend `Repository` with an explicit `Kind` (`.git` / `.plain`) and +a capabilities value (`supportsWorktrees`, `supportsBranchOperations`, +`supportsPullRequests`, `supportsDiff`, `supportsGitStatus`, +`supportsRunnableFolderActions`, `supportsRepositoryGitSettings`). `kind` expresses what +the repository *is*; capabilities express what UI and reducers *may do* with it. Views +prefer capabilities over direct kind checks. Explicit principle: no fake worktrees for +non-git folders. + +**Selection model.** Repository selection becomes a first-class concept distinct from +worktree selection. Plain folders are selected at the repository level; git repositories +keep worktree-level selection, and their sidebar header row remains an expand/collapse +control. + +**Persistence.** Replace path-only persistence (`repositoryRoots: [String]`) with an +explicit `PersistedRepositoryEntry { path, kind }`. Legacy roots keep decoding and are +migrated to entries on the first save. The startup snapshot cache +(006-startup-performance) gets a schema bump so it can carry `kind` and treat +zero-worktree repositories as valid content. + +**Add/reload flow.** The file importer's URLs are first tried as git repositories; on +success a `.git` entry is stored for the resolved root, on failure a `.plain` entry is +stored for the original folder — "not a git repository" becomes a supported path instead +of an error. Reload additionally auto-upgrades a `.plain` entry to `.git` when the path +has become its own repository root (e.g. after `git init`), and conservatively +downgrades `.git` to `.plain` only when the path definitively stopped being a repository +root (transient probe failures must not downgrade). + +**Capability gating.** Toolbar, context menus, command palette, and +`RepositorySettingsFeature` sections are driven by the selected target's capabilities: +plain folders keep Open / Run Script / Custom Commands / Copy Path / repository settings +/ Remove; branch rename, PR actions, diff actions, and worktree actions are hidden. +Settings skip git metadata loading when git capabilities are absent. Plain repositories +contribute nothing to `WorktreeInfoWatcher` feeds (PR refresh, line-change scheduling). + +**Milestones** (executed in order): 1. domain + persistence, 2. discovery/loading, +3. selection + detail view, 4. capability gating, 5. settings reuse, +6. validation/cleanup. Model work lands before broad UI refactors so the app keeps +compiling per milestone. + +## Alternatives & decisions + +- **No fake worktrees.** A synthetic-worktree shortcut would have shipped faster but was + explicitly rejected in the plan as long-term complexity; selection and detail were + reworked instead. +- **Capabilities over kind checks.** Chosen so later features (e.g. workspaces, which + reuse `.plain`) gate behavior uniformly instead of re-deriving what "plain" means. +- **Explicit persistence migration** instead of inferring entry kind from paths at every + load; the detected kind is persisted after the first successful save. +- **Kept over upstream's version.** Upstream shipped its own non-git folder support about + a month later (upstream #257, `68e44966`, reviewed in the 2026-04-20 change-list + batch); the fork's implementation predates it and was kept — that batch recorded no + action for it. + +## Amendments + +- Updated 2026-07-11: plain→git upgrade watchers (react to `git init` immediately) and + their hardening — see [002-plain-upgrade-watchers.md](002-plain-upgrade-watchers.md) diff --git a/docs-ai/010-plain-folder-support/001-action.md b/docs-ai/010-plain-folder-support/001-action.md new file mode 100644 index 00000000..41921507 --- /dev/null +++ b/docs-ai/010-plain-folder-support/001-action.md @@ -0,0 +1,76 @@ +# 010 — Plain Folder Support: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-24 | Full implementation in one PR: `Repository.Kind` + capabilities, `PersistedRepositoryEntry` migration, plain-folder add/reload/upgrade/downgrade, repository-level selection, folder toolbar title, capability-gated settings/palette, Canvas round-trip for plain folders; ~2.7k lines incl. tests | PR #48 (`0db1a91c`) | +| 2026-03-27 | Repo header tab count fixed for plain folders: count terminal state keyed by `repository.id`, extracted `RepositorySectionView.openTabCount` + tests | PR #80 | +| 2026-04-01 | Terminal layout restore fixed for plain folders (part of the layout-persistence work) | PR #120 → [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md) | +| 2026-05-25 | Active Agents selection fixed for plain folders | PR #344 → [029-active-agents-panel](../029-active-agents-panel/000-plan.md) | +| 2026-07-08 | Contributor PR: watch `.plain` roots for `.git` creation so `git init` updates the sidebar immediately (closed unmerged; commit retained in #553) | PR #548 | +| 2026-07-11 | Upgrade watchers merged + hardened (failure isolation, injectable monitors, edge-triggered detection) | PR #553 — see [002-plain-upgrade-watchers.md](002-plain-upgrade-watchers.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Domain/Repository.swift` — `Repository.Kind` (`.git`/`.plain`) and nested + `Repository.Capabilities` with `.git`/`.plain` presets, derived from `kind` via the + `capabilities` computed property. Since 042-project-workspaces, `Repository` also + carries `workspace: ProjectWorkspace?` and the initializer forces `kind = .plain` + whenever a workspace payload is present. +- `supacode/Domain/PersistedRepositoryEntry.swift` — `{ path, kind }` as planned. +- `supacode/Clients/Repositories/RepositoryPersistenceClient.swift` — decodes legacy + `[String]` roots as `.git` entries; `RepositorySnapshotCachePayload` + (`currentVersion = 2`) persists `kind` per `SnapshotRepository` and restores + zero-worktree repositories. Storage keys live in + `supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift`. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift` — + `upgradedRepositoryEntriesIfNeeded` normalizes paths, detects workspaces (forced + `.plain`), upgrades `.plain` → `.git` when the path is its own repo root, and + downgrades `.git` → `.plain` only on a definitive "not a git repository" error while + the path still exists. +- Selection: `supacode/Features/Repositories/Views/SidebarSelection.swift` has a + first-class `.repository(Repository.ID)` case next to `.worktree`; + `RepositoriesFeature.Action.selectRepository` drives it + (`supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`). Shelf and + Default View dispatch `.selectRepository` for plain folders and `.selectWorktree` for + worktrees (`RepositoriesFeature+Selection.swift`, `AppFeature+Support.swift`). +- Terminal: plain folders reuse the worktree-keyed terminal infrastructure — + `WorktreeTerminalManager` state is keyed by `repository.id` (both `Worktree.ID` and + `Repository.ID` are path-derived `String`s). `RepositorySectionView.openTabCount` + (used by `RepoHeaderRow`) counts worktree tabs for git repos and the + repository-keyed state for plain folders (#80). +- Toolbar: `supacode/Features/Repositories/Views/DetailToolbarTitle.swift` renders + `.folder(name:)` with the `folder` SF Symbol for `.plain` repositories (a + `.workspace` case was added later by 042). +- Settings: `supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift` + keeps `Repository.Capabilities` in state, hides worktree/diff/PR sections by + capability, and skips git loading unless `supportsRepositoryGitSettings`. +- Watchers: `plainRepositoryRootsForInfoWatcher()` in + `RepositoriesFeature+StateQueries.swift` feeds `.plain` non-workspace roots to + `WorktreeInfoWatcherManager`, which upgrades entries live on `git init` (see + [002-plain-upgrade-watchers.md](002-plain-upgrade-watchers.md)). +- CLI: `supacode/CLIService/Shared/ListCommandPayload.swift` exposes `kind` + (`git`/`plain`) per target in `prowl list`. + +User-facing behavior is documented in `docs/concepts.md` and +`docs/components/repositories-and-worktrees.md`. + +## Deviations from plan + +- The capabilities type is nested (`Repository.Capabilities`), not a standalone + `RepositoryCapabilities`, and gained a `supportsCodeHost` flag beyond the planned set. +- The plan suggested repository rows should "likely" become selectable for git + repositories too; #48 deliberately kept the git repo header as an expand/collapse + control (its manual acceptance tests assert this), so only plain folders select at the + repository level. +- "Avoid fake worktrees" holds at the domain level (no synthetic `Worktree` values), but + the terminal layer reuses the `Worktree.ID` key space with the repository ID rather + than introducing a separate keying concept — a pragmatic reuse the plan did not spell + out. +- Immediate reaction to `git init` was not part of the original plan (upgrade only ran on + load/reload); it arrived 3.5 months later via #548/#553. + +## Open questions + +- None. diff --git a/docs-ai/010-plain-folder-support/002-plain-upgrade-watchers.md b/docs-ai/010-plain-folder-support/002-plain-upgrade-watchers.md new file mode 100644 index 00000000..5416b865 --- /dev/null +++ b/docs-ai/010-plain-folder-support/002-plain-upgrade-watchers.md @@ -0,0 +1,54 @@ +# 010 — Amendment: Plain→Git Upgrade Watchers + +## Context + +Since #48, a `.plain` entry upgrades to `.git` only when +`upgradedRepositoryEntriesIfNeeded` runs — at app launch, scene-active refresh, or the +periodic refresh. Running `git init` inside a plain folder's terminal therefore left the +sidebar unchanged (no worktree rows, git UI hidden) until a reload happened for some +other reason. Contributor PR #548 identified the missing piece: nothing watches `.plain` +roots for `.git` creation. + +## Change + +Two waves, merged together as #553 (which retains #548's original commit unchanged): + +**#548 — the watcher path.** FSEvents monitors for `.plain` non-workspace repository +roots. When `.git` appears, emit `plainRepositoryBecameGitRepository(URL)`, which the +reducer turns into `reloadRepositories(animated: true)`; the existing upgrade logic then +reclassifies the entry and discovers worktrees. New +`WorktreeInfoWatcherClient.Command.setPlainRepositoryRoots([URL])` is sent from +`AppFeature` alongside `setWorktrees` whenever repositories change, fed by +`RepositoriesFeature.State.plainRepositoryRootsForInfoWatcher()`. + +**#553 — hardening on top.** + +- Isolate monitor creation failures: one unavailable root no longer aborts the + unordered `Set` iteration and skips the remaining roots. +- Injectable `PlainRepositoryFileEventMonitorFactory` so + `WorktreeInfoWatcherManagerTests` can cover root addition/removal, cancellation, + debounce, and stop behavior deterministically. +- Edge-triggered detection: a root that already fired keeps a + `plainRepositoryRootsWithGitEvent` marker so a failed upgrade cannot turn every + subsequent file event into a full repository reload; the trigger re-arms if `.git` + disappears again. + +## Refs + +- PR #548 (closed unmerged; commit retained) — "Fix sidebar not updating after git init + in plain repository" +- PR #553 (merged 2026-07-11) — "Harden plain repository upgrade watchers"; validated + end-to-end by opening a plain folder, running `git init`, and watching `prowl list` + flip the target from `kind=plain` to `kind=git` +- Sibling watcher-correctness work: + [034-worktree-watcher-correctness](../034-worktree-watcher-correctness/000-plan.md) + (#553 is also listed there as part of the watcher hardening arc) + +## Current state + +Verified in `supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift`: +per-root monitors in `plainRepositoryMonitors`, a 1-second `KeyedDebouncer<URL>`, a +`.git` existence check before emitting, and the edge-trigger set with re-arm. Event and +command types live in +`supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift`; the reducer +handling is in `RepositoriesFeature+CoreReducer.swift`. diff --git a/docs-ai/011-canvas-multiselect-broadcast/000-plan.md b/docs-ai/011-canvas-multiselect-broadcast/000-plan.md new file mode 100644 index 00000000..6e5b2399 --- /dev/null +++ b/docs-ai/011-canvas-multiselect-broadcast/000-plan.md @@ -0,0 +1,118 @@ +# 011 — Canvas Multi-Select & Broadcast Input: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-25 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #53 (replaces #52, closed due to branch rename) | +| **Sources** | `doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-design.md`, `doc-onevcat/plans/2026-03-25-canvas-multiselect-broadcast-implementation-plan.md` (absorbed here; originals removed in the docs-ai migration), PR #53 description | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md), [012-keybinding-system](../012-keybinding-system/000-plan.md), `docs/components/canvas.md` | + +## Background + +Canvas (entry 005) was fundamentally a single-focus experience: `CanvasView` stored one +`focusedTabID`, only the focused card allowed terminal hit testing, and Canvas exit used +the focused card to decide which worktree/tab to restore. Two user scenarios motivated +multi-card input: + +1. Open multiple cards backed by different agents and send the same prompt to compare + results. +2. Operate multiple remote SSH sessions and apply the same command/configuration to all. + +A constraint from existing code: terminal command routing was mostly worktree-scoped, +while Canvas cards are tab-scoped (`TerminalTabID`) — broadcast needed tab-level routing. + +## Goals + +- Natural multi-card selection on macOS: `Cmd+Click` anywhere on a card (terminal content + included, not title-bar-only). +- Direct typing into the Canvas — no separate batch-input textbox. +- Correct non-English (IME) behavior: followers receive committed text, never phonetic + composition keystrokes (`你好`, not `nihao`). +- Preserve existing single-card interaction when multi-select is not active. +- Select all (`Cmd+Opt+A` + toolbar button), `Escape` to clear broadcast selection. + +### Non-goals (v1) + +- Broadcasting mouse interactions, search UI, text selection, or context menus. +- Mirroring IME candidate/preedit UI to follower cards. +- Perfect behavior for all full-screen TUIs (`vim`, `fzf`, `top`, ...). +- Changing sidebar multi-selection or worktree detail selection outside Canvas. + +## Design / Approach + +**Selection model.** Focus and selection are distinct: *focus* decides where real +AppKit/Ghostty input originates; *selection* decides which cards receive mirrored input. +Exactly one selected card is the **primary** (real first responder, owns IME preedit, +source of mirrored input, decides Canvas-exit target); the rest are **followers**. A pure +value type `CanvasSelectionState` (`supacode/Features/Canvas/Models/CanvasSelectionState.swift`) +holds `mode` (`.idle`/`.selecting`), `selectedTabIDs`, `primaryTabID`, `selectionOrder`, +with mutations `focusSingle` / `toggleSelection` / `setPrimary` / `selectAll` / +`beginBroadcastInteractionIfNeeded` / `clear` / `prune`. It lives as Canvas-local +`@State` in `CanvasView` — deliberately not TCA reducer state, since the behavior is +Canvas-local and UI-driven; the pure struct keeps transitions testable without SwiftUI. + +**Cmd+Click anywhere: selection shield.** The focused terminal would normally steal +clicks, so while `Cmd` is held or selection mode is active, a transparent hit-testing +shield overlays each card. During broadcasting (≥2 selected, mode back to `.idle`), +the shield is per-card: followers keep it (click promotes to primary), the primary drops +it (clicks pass through to the terminal). Because `CommandKeyObserver` has a 300 ms hold +delay (built for shortcut-hint UI), tap handlers read `NSEvent.modifierFlags` directly +for reliable Cmd detection; the observer only drives shield rendering. + +**Broadcast fan-out, two categories:** + +1. *Committed text* — English text, committed IME text, and pasted text. Taken from the + primary card and inserted verbatim into each follower via `ghostty_surface_text`. +2. *Normalized special keys* — Enter, Backspace/Delete, arrows, Tab, Escape, control + characters (Ctrl-C/D/L, ...), modeled as `MirroredTerminalKey` (Sendable; stores + `modifierFlagsRawValue: UInt`). A static whitelist `commandAllowedKeyCodes` admits only + `Cmd+Backspace` (51) and `Cmd+Arrow` (123–126); every other Cmd combination fails + normalization so app shortcuts (`Cmd+C`, `Cmd+W`, `Cmd+Q`) never broadcast. + +**IME rule (the most important one).** The primary card runs the full native IME +lifecycle (marked text, candidate window, commit, cancel). Followers render no preedit; +they receive only the final committed string when composition commits. Intentional +design, not degradation — it is the only safe way to keep multilingual input correct. + +**Plumbing.** `GhosttySurfaceView` gains `onCommittedText` / `onMirroredKey` callbacks +(fired from `insertText()` and `keyDown()` on the primary) and safe follower APIs +`insertCommittedTextForBroadcast(_:)` / `applyMirroredKeyForBroadcast(_:)` that never +steal first responder. Tab-scoped helpers `insertCommittedText(_:in:)` / +`applyMirroredKey(_:in:)` on `WorktreeTerminalState` plus `stateContaining(tabId:)`, +`broadcastCommittedText`, `broadcastMirroredKey` on `WorktreeTerminalManager` do the +lookup and fan-out (failures logged via `SupaLogger`). Paste (Cmd+V) is broadcast by +reading `NSPasteboard.general` after Ghostty handles the paste binding. + +**UX affordances.** Primary card: 2 pt accent ring; followers: 1.5 pt accent ring at 65% +opacity + background tint. A capsule badge "Broadcasting to N cards" appears in the +bottom-right toolbar next to a Select All button. Clicking blank canvas clears selection +(0-selection allowed); exiting Canvas returns to the primary card's worktree/tab. + +Delivery was planned in three slices: (1) selection state + shield + styling, +(2) tab-scoped helpers + committed-text/special-key broadcast, (3) IME hardening, paste, +Cmd whitelist, select-all/Escape, per-card shield polish. + +## Alternatives & decisions + +- **Rejected: title-bar-only multi-select** — in Canvas the card is the object; users + must be able to Cmd+Click the terminal area too. Led to the shield design. +- **Rejected: dedicated batch-input textbox** — makes broadcast feel indirect and unlike + a terminal; direct typing into the primary card is the intended interaction. +- **Rejected: full raw-event mirroring for IME** — would propagate phonetic composition + keys (`nihao`, romaji). Commit-text mirroring chosen; correct multilingual output beats + perfect preedit mirroring. +- **Rejected: separate `onPasteText` callback** — paste reuses `onCommittedText` + plumbing instead of adding a parallel callback. +- **Selection state stays in the view, not TCA** — no reducer involvement for v1; the + pure struct provides testability without the ceremony. +- **Select-all shortcut wobble** — during implementation select-all briefly shipped as + `Cmd+Shift+A`, then was reverted to the designed `Cmd+Opt+A` before merge + (commit 93ed60e2). + +## Amendments + +None. Later Canvas-wide evolution that touched this machinery (primary auto-advance on +card close, keybinding-system integration) is recorded in +[001-action.md](001-action.md) under current state and belongs to entries 024 and 012. diff --git a/docs-ai/011-canvas-multiselect-broadcast/001-action.md b/docs-ai/011-canvas-multiselect-broadcast/001-action.md new file mode 100644 index 00000000..44e8c726 --- /dev/null +++ b/docs-ai/011-canvas-multiselect-broadcast/001-action.md @@ -0,0 +1,80 @@ +# 011 — Canvas Multi-Select & Broadcast Input: Action Log + +All work landed in a single day through one PR (#53, merged 2026-03-25, branch +`feature/canvas-multiselect-broadcast`; it replaces #52, which was closed after a branch +rename). The commit chain maps cleanly onto the plan's three slices. + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-25 | Design doc committed alongside the work | `431fe890` | +| 2026-03-25 | Core feature: `CanvasSelectionState` + tests, CanvasView integration (z-order primary > selected > unselected), card visuals + selection shield, `MirroredTerminalKey` + tests, Ghostty broadcast hooks and follower APIs, tab-scoped fan-out on terminal state/manager | `ad587965` (PR #53) | +| 2026-03-25 | Polish: `Sendable` via raw modifier storage, safe `selectionState` capture in callbacks, canvas scroll-direction fix; debug logging removed | `057c3ecd`, `9325b1c3` | +| 2026-03-25 | Click behavior during broadcasting: per-card shield (`showsSelectionShield(for:)`), non-Cmd click on follower promotes it to primary, primary click passes through | `79bdd210` | +| 2026-03-25 | Whitelist `Cmd+Backspace` and `Cmd+Arrow` for broadcast (`commandAllowedKeyCodes`) | `b00fbabd` | +| 2026-03-25 | Select all cards with `Cmd+Opt+A` + toolbar button | `c993df57` | +| 2026-03-25 | Paste broadcast: Cmd+V hook, moved to `performKeyEquivalent` after Ghostty handles the binding; select-all reverted from an interim `Cmd+Shift+A` back to `Cmd+Opt+A`; context-menu paste also broadcasts via `paste(_ sender:)` | `8acfada0`, `93ed60e2`, `4480511b` | +| 2026-03-25 | Plan/design docs aligned with final implementation; PR #53 merged | `83d812fb`, `9f746c65` | + +## Outcome & current state (as of 2026-07-12) + +The feature works as designed and remains user-facing documented in +`docs/components/canvas.md` ("broadcast to every agent"). Key code, verified in the tree: + +- `supacode/Features/Canvas/Models/CanvasSelectionState.swift` — the pure selection + struct as planned. It has since gained `pruneAutoAdvancingPrimary(previousOrder:currentOrder:)`, + which auto-focuses the nearest surviving neighbor when the primary card closes + (PR #226, 2026-04-20 — part of [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md)). +- `supacode/Features/Canvas/Views/CanvasView.swift` — selection `@State`, per-card + `showsSelectionShield(for:)`, toolbar with Select All button (`checkmark.rectangle.stack`) + and the "Broadcasting to N cards" badge, Escape/select-all key handling. +- `supacode/Features/Canvas/Views/CanvasView+Focus.swift` — broadcast wiring was later + split out of `CanvasView.swift` into this extension: `handleSelectionShieldTap`, + `syncBroadcastCallbacks` / `clearBroadcastCallbacks`. +- `supacode/Features/Canvas/Views/CanvasCardView.swift` — shield overlay plus terminal + hit testing gated by `allowsHitTesting(isFocused && !showsSelectionShield)`. +- `supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift` — normalized key model + with the `commandAllowedKeyCodes` whitelist, unchanged in shape. +- `GhosttySurfaceView` was later split into extensions; the broadcast pieces now live in: + `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` (callback declarations), + `GhosttySurfaceView+TextInput.swift` (`insertText` commit hook, + `insertCommittedTextForBroadcast`, `applyMirroredKeyForBroadcast`), and + `GhosttySurfaceView+Keyboard.swift` (`keyDown` mirror hook, Cmd+V pasteboard broadcast + in `performKeyEquivalent`, and the `paste(_ sender:)` IBAction hook). +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — + `insertCommittedText(_:in tabId:)` / `applyMirroredKey(_:in:)`. A sibling overload + `insertCommittedText(_:in surfaceID: UUID)` was added later and is used by the + `prowl send` CLI path (`supacode/App/supacodeApp.swift`) — the broadcast insertion API + became the CLI's text-injection primitive (see [013-prowl-cli](../013-prowl-cli/000-plan.md)). +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — + `stateContaining(tabId:)`, `broadcastCommittedText`, `broadcastMirroredKey`. +- Select-all is no longer hardcoded: it resolves through the keybinding system + (`AppShortcuts.CommandID.selectAllCanvasCards`, default `Cmd+Opt+A` in + `supacode/App/AppShortcuts.swift`), so users can rebind it — see + [012-keybinding-system](../012-keybinding-system/000-plan.md). +- Tests exist and grew with the feature: `supacodeTests/CanvasSelectionStateTests.swift` + (15 tests, including auto-advance coverage) and + `supacodeTests/MirroredTerminalKeyTests.swift` (8 tests). + +## Deviations from plan + +- **Select-all shortcut**: implemented mid-PR as `Cmd+Shift+A`, reverted to the designed + `Cmd+Opt+A` before merge (`93ed60e2`); later made user-configurable via the keybinding + system (entry 012). +- **Paste hook placement**: the design settled on firing `onCommittedText` from + `performKeyEquivalent` (Cmd+V is intercepted by Ghostty's binding system before the + responder chain's paste action). In the final code both paths fire the callback: + `performKeyEquivalent` covers keyboard Cmd+V, and the `paste(_ sender:)` IBAction + covers context-menu paste (`4480511b`) — the two entry points are disjoint, so no + double broadcast for a single paste. +- Otherwise the implementation matches the plan closely; note the absorbed plan docs + were themselves updated at merge time (`83d812fb`) to describe the final state. + +## Open questions + +- The original design doc contradicted itself on `paste(_ sender:)` (one section said + the IBAction is unused because Ghostty intercepts Cmd+V, another said paste broadcast + fires from `paste()`). Current code resolves this by hooking both paths, but the + behavior was reconstructed from code reading, not runtime verification of the + context-menu paste broadcast. diff --git a/docs-ai/012-keybinding-system/000-plan.md b/docs-ai/012-keybinding-system/000-plan.md new file mode 100644 index 00000000..463aa01d --- /dev/null +++ b/docs-ai/012-keybinding-system/000-plan.md @@ -0,0 +1,94 @@ +# 012 — Keybinding System: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-27 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #79, #87, #88, #100 (re-land of #95), #117, #118, #255 | +| **Sources** | Fork issues #55, #71, #72 (milestones #82–#86), PR descriptions; [architecture.md](architecture.md) (living architecture reference kept in this folder) | +| **Related** | [002-custom-commands](../002-custom-commands/000-plan.md), [007-ghostty-embedding-integration](../007-ghostty-embedding-integration/000-plan.md), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md), `docs/reference/keyboard-shortcuts.md` | + +## Background + +Before this work, app shortcuts were hard-coded in `AppShortcuts`, custom command +shortcuts lived in a separate ad-hoc registry, and three consumers — menu items, the +command palette, and the Ghostty `--keybind` CLI arguments — each read bindings from +their own hard-coded path. Users could not rebind anything, and several defaults +collided with terminal muscle memory (`⌘[`/`⌘]`, and the contested role of `⌘B` — +discussion in fork issue #55). Issue #55 was split into two planning issues: + +- **#71** — default-shortcut alignment and conflict precedence rules. +- **#72** — a config-driven keybinding system with a recorder UI, including research + into third-party frameworks (KeyboardShortcuts, MASShortcut, HotKey). + +## Goals + +- A versioned keybinding schema covering every command, with scope, overridability, and + conflict-policy metadata (four scopes: `configurableAppAction`, `systemFixedAppAction`, + `localInteraction`, `customCommand`). +- Deterministic resolution with layered precedence: `appDefault` → `migratedLegacy` → + `userOverride`; non-overridable commands ignore overrides entirely. +- One resolver output (`ResolvedKeybindingMap`) feeding all consumers: menus, command + palette labels, SwiftUI environment, and Ghostty CLI keybind arguments. +- Shortcuts Settings page with an inline key recorder, conflict detection/replacement, + and cascading reset back to defaults. +- Automatic migration of legacy custom-command shortcuts into the new override store. +- Minimal default remap per #71, and releasing terminal-critical combinations to Ghostty. + +### Non-goals + +- Hard-blocking conflicting user shortcuts: conflicts warn and prefer the user override + (decision in #71/#79), rather than refusing to save. +- Managing Ghostty-native bindings (splits, terminal actions bound in the user's Ghostty + config): those stay owned by Ghostty; Prowl only unbinds/binds what it needs. + +## Design / Approach + +The work was milestone-split under umbrella issue #72 and integrated on a dedicated +branch (`feature/issue-72-keybinding-integration-base`) that merged into `main` as one +reviewed unit (#118): + +- **M1 (#82 → PR #87)** — data layer: `KeybindingSchemaDocument` (versioned schema), + `KeybindingResolver` (precedence merge), `LegacyCustomCommandShortcutMigration` + (old `UserCustomCommand.shortcut` → override entries), plus schema/resolver tests. +- **M2 (#83 → PR #88)** — routing: menu shortcut display/registration, command palette + labels, and Ghostty CLI argument generation all read from resolver-backed helpers + keyed by command ID, replacing three separate hard-coded paths. +- **M3 (#84 → PR #95/#100)** — runtime sync + UI: Shortcuts Settings table layout, + resolved-binding hints across the UI, configurable local-interaction shortcuts. +- **M4 (#85, landed inside #118)** — custom commands UI revamp sharing the same + recorder and conflict engine. +- **M5 (#86 → PR #117 + docs in #118)** — behavior matrix tests (scope × policy × + state) and the agent-facing architecture reference (now [architecture.md](architecture.md)). + +Ghostty integration generates two kinds of CLI args from the resolved map: `=unbind` +for app-owned shortcuts (so Ghostty does not intercept them) and bind args for +terminal actions (e.g. `goto_tab:N`), re-synced whenever bindings change. The full +mechanics (scopes, policies, storage, reset cascade) are documented in +[architecture.md](architecture.md) — not duplicated here. + +## Alternatives & decisions + +- **Minimal default remap** (#71 owner plan, implemented in #79): `⌃⌘S` Toggle Sidebar + (was `⌘[`), `⇧⌘U` Check for Updates (was `⌘U`), `⇧⌘Y` Show Diff (was `⌘]`); `⌘B` left + unassigned for build/custom-command semantics. +- **Warn, don't block** (#79): a custom shortcut conflicting with an app default is + persisted and wins (`result=customOverride` warning log), instead of hard rejection. +- **Release terminal-critical combos** (#79): `⌘[`/`⌘]`, `⇧⌘[`/`⇧⌘]`, `⌘D`/`⇧⌘D` are no + longer unbound from Ghostty, preserving native tab/split behavior. +- **Self-built recorder over third-party frameworks**: #72 planned framework research; + the shipped implementation uses a plain `NSEvent.addLocalMonitorForEvents` recorder + with `ShortcutKeyTokenResolver`, and no third-party shortcut dependency exists in the + tree. +- **Integration-base branch flow**: milestones merged into a feature base branch and + reviewed once at #118. The M3 PR #95 was accidentally merged to `main`, reverted by + #99, and re-landed as #100 against the base branch. + +## Amendments + +- Updated 2026-05-08: Ghostty key-equivalent ownership fix (#255, upstream port) — see + [002-ghostty-key-equivalent-ownership.md](002-ghostty-key-equivalent-ownership.md) +- Updated 2026-05-24: chained/sequence/performable bindings invisible to shortcut-hint + reverse lookup; fallback PR #334 closed as unnecessary — see + [003-chained-binding-hint-limitation.md](003-chained-binding-hint-limitation.md) diff --git a/docs-ai/012-keybinding-system/001-action.md b/docs-ai/012-keybinding-system/001-action.md new file mode 100644 index 00000000..c1cce89f --- /dev/null +++ b/docs-ai/012-keybinding-system/001-action.md @@ -0,0 +1,62 @@ +# 012 — Keybinding System: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-27 | Default-shortcut alignment + conflict fallback: minimal remap (`⌃⌘S` sidebar, `⇧⌘U` updates, `⇧⌘Y` diff), custom shortcuts persist as user overrides with warning on conflict, terminal-critical combos released to Ghostty | PR #79 (issue #71) | +| 2026-03-28 | M1: versioned schema (`KeybindingSchemaDocument`), `KeybindingResolver` with appDefault → migratedLegacy → userOverride precedence, `LegacyCustomCommandShortcutMigration`, unit tests | PR #87 (issue #82) | +| 2026-03-29 | M2: menu display/registration, command palette labels, and Ghostty CLI keybind args all routed through resolver-backed helpers keyed by command ID | PR #88 (issue #83) | +| 2026-03-30 | M3: runtime sync + UI hints, table-style Shortcuts Settings, configurable local-interaction shortcuts. Accidentally merged to `main` as #95, reverted by #99, re-landed onto the integration base branch as #100 | PRs #95, #99, #100 (issue #84) | +| 2026-04-01 | M5 tests: 24-case behavior matrix (scope × policy × state: defaults, overrides, disable, migration precedence, conflict detection, cascading reset, persistence round-trip, edge cases) | PR #117 (issue #86) | +| 2026-04-01 | Umbrella merge of `feature/issue-72-keybinding-integration-base` into `main`: recorder UI, conflict alerts, cascading reset planner, custom commands UI revamp (M4, issue #85), agent architecture doc; 60 files, +5711/−668 | PR #118 (issues #72, #86) | +| 2026-05-08 | Ghostty key-equivalent ownership fix (upstream port) — see [002](002-ghostty-key-equivalent-ownership.md) | PR #255 | +| 2026-05-24 | Chained-binding shortcut-hint limitation identified as Ghostty reverse-map behavior; fallback PR closed unmerged — see [003](003-chained-binding-hint-limitation.md) | PR #334 (closed) | + +## Outcome & current state (as of 2026-07-12) + +All of the following verified against the working tree: + +- `supacode/App/KeybindingSchema.swift` — `KeybindingSchemaDocument`, + `KeybindingUserOverrideStore`, `KeybindingResolver`, + `LegacyCustomCommandShortcutMigration`, and the `appDefaultsV1` bridge from + `AppShortcuts.bindings`. +- `supacode/App/AppShortcuts.swift` — built-in command registry plus + `ghosttyCLIKeybindArguments(from:)` generating unbind/bind args from a + `ResolvedKeybindingMap`; display helpers for palette/menu hints. +- `supacode/Features/App/Reducer/AppFeature+Support.swift` — `resolvedKeybindings(...)` + recompute: migrates legacy custom-command shortcuts, resolves, and lets custom + commands win over conflicting app-level bindings. +- `supacode/App/ResolvedKeybindingsEnvironment.swift` — SwiftUI environment key. +- `supacode/App/supacodeApp.swift` — passes keybind args at Ghostty init and re-syncs + on changes. +- `supacode/Features/Settings/Views/ShortcutsSettingsView.swift` — settings page and + `NSEvent.addLocalMonitorForEvents`-based recorder. Note: `ShortcutResetPlanner` is an + enum declared **inside this view file** (line ~742), not a separate file under + `BusinessLogic/` as [architecture.md](architecture.md) currently claims. +- `supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift` and + `ShortcutKeyTokenResolver.swift` — conflict detection and NSEvent → key-token mapping. +- `supacode/Features/Settings/Models/GlobalSettings.swift` — `keybindingUserOverrides` + persisted via the shared settings file. +- Tests in `supacodeTests/`: `KeybindingSchemaTests.swift`, + `KeybindingBehaviorMatrixTests.swift`, `ShortcutConflictDetectorTests.swift`, + `ShortcutResetPlannerTests.swift`, `ShortcutKeyTokenResolverTests.swift`, + `AppShortcutsTests.swift`, `GhosttySurfaceViewTests.swift`. +- User-facing shortcut table: `docs/reference/keyboard-shortcuts.md`. + +## Deviations from plan + +- The M1 design note (`doc-onevcat/keybinding-m1-design.md`, mentioned in PR #87's + description) never landed on `main`; its substance survives only in the PR body and + in [architecture.md](architecture.md). +- M3 landed twice due to the #95 mis-merge (reverted by #99, re-landed as #100); the + final content is identical. +- Issue #72's framework research (KeyboardShortcuts/MASShortcut/HotKey) produced no + adopted dependency; the recorder is self-built. + +## Open questions + +- [architecture.md](architecture.md) lists `ShortcutResetPlanner` at + `supacode/Features/Settings/BusinessLogic/ShortcutResetPlanner.swift`, but the type + is declared inside `supacode/Features/Settings/Views/ShortcutsSettingsView.swift`. + The living doc should be corrected during migration (or the type extracted to match). diff --git a/docs-ai/012-keybinding-system/002-ghostty-key-equivalent-ownership.md b/docs-ai/012-keybinding-system/002-ghostty-key-equivalent-ownership.md new file mode 100644 index 00000000..9a824cc4 --- /dev/null +++ b/docs-ai/012-keybinding-system/002-ghostty-key-equivalent-ownership.md @@ -0,0 +1,32 @@ +# 012 — Amendment: Ghostty Key Equivalent Ownership (2026-05-08) + +## Context + +Since M2/M3, `GhosttySurfaceView.performKeyEquivalent` routes keys bound in Ghostty to +the surface first, and only lets unbound keys fall through to the app. Upstream +identified two edge cases in this routing (upstream #259 `6c807c63`, upstream #264 +`539c0feb`): a surface could consume key equivalents while not actually being the first +responder, and Ghostty-bound or custom shortcuts were forwarded to the main menu even +when no menu item carried that exact shortcut. + +## Change + +Ported to the fork as #255 during the 2026-05-08 upstream review batch (see +[../017-upstream-sync-process/upstream-ledger.md](../017-upstream-sync-process/upstream-ledger.md)): + +- Require the Ghostty surface to be the actual first responder before it handles key + equivalents. +- Forward Ghostty-bound or user-custom shortcuts to the main menu only when an exact + menu item shortcut exists. +- Regression coverage for shifted menu-shortcut matching and focus ownership. + +## Refs + +- PR #255 (fork port); upstream #259, #264. +- Upstream ledger entry 2026-05-08 ("Review through post-v0.8.5"). + +## Current state + +`supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift` implements the +`performKeyEquivalent` routing; tests live in +`supacodeTests/GhosttySurfaceViewTests.swift`. diff --git a/docs-ai/012-keybinding-system/003-chained-binding-hint-limitation.md b/docs-ai/012-keybinding-system/003-chained-binding-hint-limitation.md new file mode 100644 index 00000000..75035ebd --- /dev/null +++ b/docs-ai/012-keybinding-system/003-chained-binding-hint-limitation.md @@ -0,0 +1,43 @@ +# 012 — Amendment: Chained-Binding Shortcut-Hint Limitation (2026-05-24) + +## Context + +Shortcut hints for Ghostty-native actions (e.g. `new_split:right`) are resolved by +reverse lookup: `GhosttyRuntime.keyboardShortcut(for:)` calls `ghostty_config_trigger` +to map an action string back to its trigger. In May 2026 the split-creation hints in +the tab bar showed empty even though the keys worked inside the surface. + +## Finding + +**Known limitation:** Ghostty deliberately excludes chained (`chain=`), sequence +(`a>b`), and `performable:` triggers from its reverse map (the `track_reverse` handling +in `ThirdParty/ghostty/src/input/Binding.zig`), because such triggers cannot be +expressed as a single GUI menu accelerator. `ghostty_config_trigger` therefore returns +an empty trigger for any action bound *only* through such a trigger — so Prowl's +shortcut hints show empty for it. Plain (non-chained) custom rebinds resolve fine. + +The observed case was user config, not a Prowl bug: the local Ghostty config chained +`equalize_splits` onto `super+d=new_split:right` / `super+shift+d=new_split:down`, +which removed those bindings from the reverse map. A probe against the default Ghostty +config confirmed split actions resolve normally when unchained. + +## Decision + +- PR #334 ("Show split shortcut hints when bindings are chained", a hardcoded-fallback + approach) was **closed unmerged** on 2026-05-24 once the root cause was understood; + the user config was fixed instead. +- The limitation is accepted: reading a chained trigger would require a new + forward-search C API in the Ghostty fork (searching the forward binding map including + chained leaves) plus an xcframework rebuild — not undertaken. + +## Refs + +- PR #334 (closed 2026-05-24, unmerged). +- `ThirdParty/ghostty/src/input/Binding.zig` (reverse-map exclusion). + +## Current state + +`supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` (`keyboardShortcut(for:)`) and +`supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift` still resolve hints via +`ghostty_config_trigger`; actions bound only through chained/sequence/performable +triggers continue to show no shortcut hint. diff --git a/docs-ai/013-prowl-cli/000-plan.md b/docs-ai/013-prowl-cli/000-plan.md new file mode 100644 index 00000000..ae0308a3 --- /dev/null +++ b/docs-ai/013-prowl-cli/000-plan.md @@ -0,0 +1,122 @@ +# 013 — Prowl CLI: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-30 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | contracts #89–#94, #97, #104; foundation #126, #127; runtimes #129, #133, #135, #136, #137, #141; install #146, #151, #153; growth #139, #148, #150, #157, #384, #405, #442 | +| **Sources** | `docs-ai/013-prowl-cli/contracts/architecture.md`, `doc-onevcat/plans/2026-04-04-cli-install-command.md`, `doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md`, PR descriptions | +| **Related** | `docs-ai/013-prowl-cli/contracts/` (living normative spec), `docs/components/cli.md`, `skills/prowl-cli/SKILL.md`, [030-agent-status-detection](../030-agent-status-detection/000-plan.md) | + +## Background + +Prowl orchestrates multiple coding agents in parallel, each in its own worktree/tab/pane. +Both users and the agents themselves need a machine interface to a *running* GUI +instance: list worktrees/tabs/panes, focus a target, send text or key events, read a +pane's buffer, and open a path. An earlier shell prototype (`bin/prowl`) mixed argv +parsing with app behavior and drifted from what the app actually did. Issue #70 tracked +the redesign; the deliberate first step was to freeze machine-readable contracts before +writing any runtime code, so that CLI and app could evolve against one truth source. + +## Goals + +- A stable machine interface (`prowl`) for a running Prowl instance, with `--json` + output an agent can script against (stable keys, stable `error.code` values, + versioned schemas). +- Contract-first: the docs under `docs-ai/013-prowl-cli/contracts/` are the normative spec, + locked before implementation; runtime work is validated against them. +- Parsing and validation live in a typed Swift CLI; command *execution* is app-owned, + giving one authoritative runtime path for both GUI- and CLI-triggered actions. +- Reuse existing repository/terminal capabilities — the CLI is a transport + contract + adapter, not a parallel runtime. + +**Non-goals (phase 1)**: remote/multi-machine transport; a first-class "switch agent" +command (pane-oriented commands suffice); auto-prompting CLI install on first launch; +uninstall UI. + +## Design / Approach + +### Contract set (the normative spec) + +The contracts are living documents — linked here, never duplicated. All of phase 1 was +specified before the first line of runtime code landed: + +| Contract doc (`docs-ai/013-prowl-cli/contracts/`) | Defines | PR | +| --- | --- | --- | +| `open.md` | `prowl open` / bare-path output contract | #89 | +| `list.md` | `prowl list` output contract | #90 | +| `focus.md` | `prowl focus` output contract | #91 | +| `send.md` | `prowl send` output contract | #92 | +| `key.md` | `prowl key` output contract | #93 | +| `read.md` | `prowl read` output contract | #94 | +| `schema.md` | v1 JSON Schemas for all command outputs | #97 | +| `input.md` | Input contract: selector model, argv/stdin rules, token/repeat constraints | #104 | +| `architecture.md` | Phase-1 architecture + app interaction plan (absorbed below) | #104 | + +### Architecture decisions (from `architecture.md`) + +- **Decision A — first-class CLI binary**: `prowl` is a Swift executable built on + ArgumentParser; the existing `bin/prowl` shell implementation is discarded. Parsing + truth and input validation live in the Swift CLI module (typed request model, unit + testability, no drift vs contracts). +- **Decision B — explicit command service boundary**: the CLI builds a normalized + command request; the app resolves the target, executes, and returns a normalized + response. The app never re-interprets argv-level ambiguity. +- **Decision C — execution is app-owned**: phase-1 commands are remote-control actions + on running app state. `open` must be able to launch the app when it is not running. + +### Module layout and protocol + +| Layer | Planned components | +| --- | --- | +| CLI (`ProwlCLI` target) | ArgumentParser commands + validation; typed inputs (`OpenInput`/`ListInput`/…); transport client; output renderer (`--json` = raw contract payload, text = readable summary) | +| App (`CLICommandService`) | Command router mapping envelope → handler; one handler per command; shared `TargetResolver` and terminal/repository bridges | +| Shared types | `CommandEnvelope`, `Command`, `CommandResponse`, `TargetSelector`, input models, stable error codes, socket constants | + +Transport: a single local IPC channel with a fixed API contract — +`request(CommandEnvelope) -> CommandResponse`. `architecture.md` deliberately left the +channel choice open; the v1 foundation (#126) fixed it as a **Unix domain socket** +carrying **length-prefixed JSON** (4-byte big-endian length + payload), one +request/response per connection, with app-not-running mapped to a stable +`APP_NOT_RUNNING` error code. Responses carry a versioned schema id +(`prowl.cli.<command>.v1`). + +Target resolution is owned by the app (state-aware): the CLI only enforces selector +syntax and mutual exclusivity (`--worktree | --tab | --pane`); the app maps the selector +to a concrete worktree/tab/pane and echoes the resolved target in the output. + +Milestones: M0 contract lock → M1 parser/runtime split (Swift target, shell discarded) +→ M2 command service scaffold (transport verified with stub handlers) → M3 phase-1 +handlers with full error-code mapping → M4 tests and hardening (parser golden tests, +`--json` schema validation, `list → focus → send/key → read` integration loops). + +### Install & distribution (absorbed from `doc-onevcat/plans/2026-04-04-cli-install-command.md`) + +Once the runtime existed, users needed a way to get `prowl` onto their `PATH` without a +package manager: + +- `CLIInstallClient` — a TCA dependency client handling symlink creation, status + checking, and bundled-binary path resolution. Status model: `.notInstalled`, + `.installed(path:)`, `.installedDifferentSource(path:)`. +- The CLI binary is embedded at `Prowl.app/Contents/Resources/prowl-cli/prowl`; + installation symlinks `/usr/local/bin/prowl` to it. +- Three entry points — Settings › Advanced, the Prowl app menu, and the Command + Palette — all funnel into a single `installCLI` action in `AppFeature`. +- Makefile gains CLI build/embed targets; the app bundle includes `Resources/prowl-cli/`. + +## Alternatives & decisions + +| Decision | Rejected alternative | Rationale (as recorded) | +| --- | --- | --- | +| Swift executable with typed parsing | Keep/extend the `bin/prowl` shell script | Strict typed requests, parser unit tests, deterministic behavior, lower drift vs contracts | +| Contract lock before runtime (M0) | Implementation-first, document later | Explicitly framed as preventing a repeat of parser-in-shell + CI churn before contract decisions were final | +| App-owned target resolution | CLI resolves selectors itself | Resolution is state-aware; CLI cannot see live app state, so it validates syntax only | +| `send --capture` via screen-buffer diff | Other capture approaches from #147 | "Approach A (Screen Buffer Diff)" chosen: snapshot before/after, diff, strip echo/prompt (#148) | +| CLI version generated from `MARKETING_VERSION` | Hand-maintained version string | Single version truth shared with the app; regenerated by the release flow (#151) | +| `prowl agents` is read-only, no switch subcommand | First-class agent-switching command | Automation resolves `pane.id` from `agents --json`, then uses existing `focus`/`read`/`send` (2026-06-13 plan) | + +## Amendments + +- Updated 2026-06-14: read-only `prowl agents` command exposing the Active Agents + roster over the CLI — see [002-agents-command.md](002-agents-command.md) diff --git a/docs-ai/013-prowl-cli/001-action.md b/docs-ai/013-prowl-cli/001-action.md new file mode 100644 index 00000000..562de75a --- /dev/null +++ b/docs-ai/013-prowl-cli/001-action.md @@ -0,0 +1,119 @@ +# 013 — Prowl CLI: Action Log + +## Timeline + +### Phase 1 — Contracts (2026-03-30 → 2026-03-31) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-30 | Output contracts for `open`, `list`, `focus`, `send`, `key`, `read` | #89, #90, #91, #92, #93, #94 | +| 2026-03-30 | v1 JSON Schemas for all command outputs (`schema.md`) | #97 | +| 2026-03-31 | Input contract (`input.md`) + phase-1 architecture plan (`architecture.md`) | #104 | + +### Phase 2 — Runtime v1 (2026-04-02 → 2026-04-06) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-02 | CLI v1 foundation: `prowl` executable target, shared envelope/response/input types, app-side router + `CLISocketServer` scaffold (stub handlers), Unix-socket length-prefixed JSON transport | #126 | +| 2026-04-02 | SPM `prowl` target wiring with smoke and integration tests | #127 | +| 2026-04-03 | `list` runtime via command service | #129 | +| 2026-04-03 | `send` runtime (argv vs stdin exclusivity honored) | #135 | +| 2026-04-03 | `focus` runtime (contract-driven) | #136 | +| 2026-04-04 | `read` runtime (viewport/screen capture, `--last N`) | #137 | +| 2026-04-04 | `key` runtime | #141 | +| 2026-04-04 | `open` runtime handler | #133 | +| 2026-04-04 | Auto-launch app when not running: `AppLauncher` polls the socket, `app_launched: true` in payload | #139 | +| 2026-04-04 | `send --capture`: pre/post screen-buffer snapshot + diff, echo/prompt stripping; invalid with `--no-wait`/`--no-enter` | #148 | +| 2026-04-05 | Auto-target resolution: `TargetSelector.auto` (pane UUID → tab UUID → worktree id/name/path), positional `<target>`, `-t/--target` flag; `input.md`/`schema.md` updated | #150 | +| 2026-04-06 | Key token expansion: general descriptor pipeline (modifier combos, printable keys, forward delete, F-keys) mapped to `NSEvent` specs | #157 | +| 2026-04-06 | Key token follow-up with ANSI control fixes | #164 | + +### Phase 3 — Install & distribution (2026-04-05) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-05 | In-app install: `CLIInstallClient`, symlink `/usr/local/bin/prowl` → bundled binary, three entry points (Settings › Advanced, app menu, Command Palette), Makefile embed targets | #146 | +| 2026-04-05 | CLI version unified with app `MARKETING_VERSION` via generated `ProwlVersion.swift` (`sync-cli-version`) | #151 | +| 2026-04-05 | Dev builds embed the debug CLI (faster iteration) | #152 | +| 2026-04-05 | Release CLI built as universal binary (arm64 + x86_64) | #153 | +| 2026-04-05 | Install feedback shown via toolbar toast for all entry points | #155 | + +### Phase 4 — Hardening (2026-04-25 → 2026-06-26) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-25 | Socket moved from `$TMPDIR` to `~/Library/Application Support/com.onevcat.prowl/cli.sock` — macOS periodically sweeps `/var/folders/.../T/`, deleting the path entry under a long-running app (`connect()` → ENOENT → spurious `APP_NOT_RUNNING`) | #239 | +| 2026-06-04 | Socket ownership: per-socket lock so secondary instances cannot unlink/replace a live owner; stale cleanup and shutdown unlink only when owning; app detection by bundle id | #387 | +| 2026-06-04 | `read` `truncated` semantics fixed: `true` now means "returned text may be incomplete", not "fewer lines than requested"; `read.md` updated | #388 | +| 2026-06-07 | Socket access hardening: owner-only permissions on directory/socket/lock; reject clients whose peer uid ≠ app uid | #404 | +| 2026-06-13 | JSON mode passes through app-encoded JSON instead of decode + re-render, fixing escaped C0 control characters in text/titles | #445 | +| 2026-06-26 | Socket errno diagnostics: distinguish missing/stale socket, sandbox permission-denied, invalid path (`SocketConnectionProbe`) | #516 | + +### Phase 5 — Capability growth & agent guidance (2026-06-04 → 2026-06-14) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-04 | `read --wait-stable` (+ `--stable-interval/--stable-period/--wait-timeout`): app-side polling until the rendered buffer stops changing; response gains `stabilized`/`waited_ms`/`samples`; prowl-cli skill hardened; skill discovery symlinks added | #384 | +| 2026-06-04 | prowl-cli `SKILL.md` YAML frontmatter fixed so strict parsers discover it | #389 | +| 2026-06-07 | `prowl tab create` / `tab close` / `pane close` commands with app-side handlers and payloads | #405 | +| 2026-06-07 | prowl-cli skill targeting guidance clarified | #407 | +| 2026-06-14 | `prowl agents` roster command — see [002-agents-command.md](002-agents-command.md) | #442 | + +## Outcome & current state (as of 2026-07-12) + +- **Package**: `Package.swift` defines `ProwlCLIShared` (path + `supacode/CLIService/Shared` — shared between app and CLI), executable `prowl` (path + `ProwlCLI`), and `ProwlCLITests`; dependencies: swift-argument-parser, Rainbow. +- **CLI**: `ProwlCLI/Commands/ProwlCommand.swift` registers nine subcommands with + `open` as default: `open`, `list`, `agents`, `focus`, `send`, `key`, `read`, `tab`, + `pane`; shared flags in `SelectorOptions.swift` / `GlobalOptions.swift`. Transport in + `ProwlCLI/Transport/SocketTransportClient.swift` and `SocketConnectionProbe.swift` + (errno diagnostics); `ProwlCLI/AppLauncher.swift` handles cold launch; + `ProwlCLI/Output/OutputRenderer.swift` renders text mode and passes through + app-encoded JSON. +- **App service**: `supacode/CLIService/` — `CLICommandRouter.swift` (stamps + `prowl.cli.<command>.v1` schema versions), `CLISocketServer.swift` (ownership lock, + owner-only permissions, peer-uid check), one handler per command + (`OpenCommandHandler.swift` … `AgentsCommandHandler.swift`), `TargetResolver.swift`, + `ListRuntimeSnapshotBuilder.swift`. +- **Shared types**: `supacode/CLIService/Shared/` — envelope/response/input/payload + models, `ErrorCodes.swift`, `SocketConstants.swift` (default socket + `~/Library/Application Support/com.onevcat.prowl/cli.sock`, `PROWL_CLI_SOCKET` + override, fallback to `NSTemporaryDirectory()` only when the path would exceed the + 104-byte AF_UNIX limit), generated `ProwlVersion.swift`. +- **Install**: `supacode/Clients/CLIInstall/CLIInstallClient.swift`; binary embedded + under `Resources/prowl-cli/`. +- **Build/test**: Makefile targets `build-cli`, `build-cli-release` (universal), + `embed-cli`, `embed-cli-debug`, `sync-cli-version`, `test-cli-smoke`, + `test-cli-integration`. +- **Agent-facing docs**: `skills/prowl-cli/SKILL.md` (discovery symlinks + `.claude/skills/prowl-cli`, `.agents/skills/prowl-cli`); user manual + `docs/components/cli.md`; normative contracts remain `docs-ai/013-prowl-cli/contracts/`. + +## Deviations from plan + +- `architecture.md` left the IPC channel abstract ("implementation choice can be + refined"); v1 fixed it as a Unix domain socket at `$TMPDIR/prowl-cli.sock` (#126), + which proved operationally wrong and was relocated to Application Support (#239). +- The command surface grew beyond the phase-1 six: `tab`, `pane` (#405) and `agents` + (#442) have runtime schema ids (`prowl.cli.tab.v1`, `.pane.v1`, `.agents.v1`) but no + contract docs under `docs-ai/013-prowl-cli/contracts/`. +- The contract's "`--json` = raw contract payload" was initially implemented as decode + + re-encode in the CLI; #445 changed it to byte pass-through of the app-encoded JSON + (arguably closer to the original contract intent). +- M4's planned schema validation of `--json` payloads against `schema.md` appears to + have landed as pinned-string tests (e.g. `supacodeTests/CLICommandRouterTests.swift` + asserts the `prowl.cli.*.v1` version strings) plus socket integration round-trips + (`ProwlCLITests/ProwlCLIIntegrationTests.swift`), not automated JSON-Schema + validation. + +## Open questions + +- `docs-ai/013-prowl-cli/contracts/send.md` still describes `--capture` as "a future + `--capture` flag" even though #148 shipped it in 2026-04 (#148 explicitly deferred the + contract-doc update; it never landed). +- `docs-ai/013-prowl-cli/contracts/read.md` does not document `--wait-stable` or the + `stabilized`/`waited_ms`/`samples` response fields (#384 documented them in the skill + and PR body only). +- No contract docs exist for `tab`, `pane`, or `agents` — the living spec covers only + the phase-1 commands, so the contract dir currently understates the CLI surface. diff --git a/docs-ai/013-prowl-cli/002-agents-command.md b/docs-ai/013-prowl-cli/002-agents-command.md new file mode 100644 index 00000000..d05d0c18 --- /dev/null +++ b/docs-ai/013-prowl-cli/002-agents-command.md @@ -0,0 +1,48 @@ +# 013.002 — `prowl agents`: Active Agents roster over the CLI + +## Context + +Issue #330 asked to expose the Active Agents roster (already shown in the sidebar +panel) through the CLI, so automation can answer "which of my agents are working / +blocked / done?" without screen-scraping. Prerequisite work made agent detection +scheduling reliable independently of whether the Active Agents panel is expanded or any +UI-only preference — the detection pipeline itself is documented in +[030-agent-status-detection](../030-agent-status-detection/000-plan.md). Plan source: +`doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md` (absorbed here). + +## Change + +A read-only `prowl agents` / `prowl agents --json` command (schema +`prowl.cli.agents.v1`). Key semantics as planned and shipped: + +| Aspect | Decision | +| --- | --- | +| Status source | Per-pane agent detection state (`working \| blocked \| done \| idle`, plus `raw_state`) — deliberately distinct from the worktree-level `task.status` (`running \| idle \| null`) that `prowl list` reports | +| Membership | Only panes with a currently detected agent or a retained Active Agents entry; empty shells and ordinary commands excluded. Idle/done entries included by default (mirrors panel retention); automation filters by status | +| Identity | `id` equals `pane.id` (surface UUID), matching Active Agents entries; `type` is the normalized detected agent, `name` preserves command aliases | +| `project` vs `worktree` | Both exposed: `project` is the display-oriented repo/branch resolved from the agent's working directory (same rules as the panel); `worktree` is the actual terminal owner used for `focus`/`read`/`send` targeting. An agent may run outside the worktree owning its pane | +| Per-agent metadata | Nested `project`, `worktree`, `tab` (with `selected`), `pane` (with `index`, `cwd`, `focused`), `last_changed_at` (ISO-8601) | +| Text output | One scannable line per agent, ranked `blocked` → `working` → `done` → `idle`, insertion order preserved within a group | +| No switch subcommand | Deliberate: resolve `pane.id` from `agents --json`, then use existing `prowl focus/read/send --pane <id>` | +| No filter flags in v1 | JSON + `jq` deemed sufficient; `--status` filters deferred | + +## Refs + +- PR #442 (merged 2026-06-14), closing issue #330. +- Plan doc: `doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md`. +- Manual validation in #442 also confirmed multi-instance socket behavior: only the app + owning the default socket serves default CLI commands; a dev instance needs a + matching `PROWL_CLI_SOCKET` on both sides. + +## Current state + +- CLI: `ProwlCLI/Commands/AgentsCommand.swift`; text rendering with the status ranking + map in `ProwlCLI/Output/OutputRenderer.swift` (`renderAgents`). +- App: `supacode/CLIService/AgentsCommandHandler.swift`; payload models in + `supacode/CLIService/Shared/AgentsCommandPayload.swift`. +- The payload later gained an optional `session` field (id + confidence, rendered as a + `session=` suffix in text mode) as part of native agent session detection — see + [045-native-agent-session-detection](../045-native-agent-session-detection/000-plan.md). +- Skill/manual coverage: `skills/prowl-cli/SKILL.md` and `docs/components/cli.md` + document the command; no contract doc exists under `docs-ai/013-prowl-cli/contracts/` + (noted in [001-action.md](001-action.md) open questions). diff --git a/docs-ai/014-terminal-layout-persistence/000-plan.md b/docs-ai/014-terminal-layout-persistence/000-plan.md new file mode 100644 index 00000000..8d663f46 --- /dev/null +++ b/docs-ai/014-terminal-layout-persistence/000-plan.md @@ -0,0 +1,105 @@ +# 014 — Terminal Layout Persistence: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-03-31 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #112, #113, #116 (umbrella for fork issue #76) | +| **Sources** | Fork issue #76, PR #77/#81/#112/#113/#116/#120–#125/#186/#380/#459 descriptions, change-list 2026-04-08 and 2026-06-09 review batches (ledger lives on as `docs-ai/017-upstream-sync-process/upstream-ledger.md`) | +| **Related** | [010-plain-folder-support](../010-plain-folder-support/000-plan.md), [022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), `docs/components/terminal.md`, `docs/components/settings.md`, `docs/reference/settings-fields.md` | + +## Background + +Prowl lost all terminal arrangement on every restart: tab and split layouts were rebuilt +from scratch and font-size adjustments (Cmd+/-) reverted to the Ghostty config default. +Fork issue #76 asked for both to persist across launches — a real pain for the +many-worktrees, many-splits workflow the app is built around. + +Persisting layout is riskier than it sounds: the snapshot is read at boot, before the +repository list is live, and a corrupted or stale snapshot could poison startup (the +adjacent repository-snapshot cache from 006-startup-performance had the same exposure). +The work was therefore split into a safety phase and a restore phase. + +## Goals + +- Persist the per-worktree tab/split tree (pane working directories, focused pane, + selected worktree) and restore it on launch. +- Persist the user's terminal font-size override independently of Ghostty's config file. +- Fail closed: an invalid, oversized, or unrestorable snapshot must be discarded (and + surfaced to the user) rather than half-applied. +- Keep the feature opt-in behind a setting (`restoreTerminalLayoutOnLaunch`, default + `false`, labeled experimental) with a manual "Clear saved terminal layout" escape hatch. + +### Non-goals + +- Persisting live shell sessions (scrollback, running processes). Restore recreates + fresh shells in the saved working directories; this is a layout snapshot, not a + terminal multiplexer. + +## Design / Approach + +Reconstructed from the phase PRs (#112, #113) and the umbrella #116. + +**Font size first (#77, #81).** The font-size override became a Prowl-local setting +(`GlobalSettings.terminalFontSize`) instead of a Ghostty config write. The value is +normalized against Ghostty's default `font-size` (reset-to-default clears the override), +synced from surface cell-size/config-change callbacks, and injected into +`WorktreeTerminalManager` as `preferredFontSize` at boot so new surfaces inherit it. + +**Phase A — persistence safety (#112).** Guardrails before any restore logic: + +- Snapshot storage moved from `~/.prowl` to + `~/Library/Application Support/com.onevcat.prowl/cache/` (both + `repository-snapshot.json` and the new `terminal-layout-snapshot.json`), with legacy + migration. +- `PathPolicy` centralizes path normalization + containment checks used by persistence + keys and worktree-cleanup safety. +- `snapshotPersistencePhase` (`idle`/`restoring`/`active`) in `RepositoriesFeature` + blocks snapshot writeback while boot-time restore is in flight. +- Fail-closed "fuses": limits on snapshot file size, repository count, and worktrees per + repository; anything over the limit is rejected and reset. +- Settings plumbing: the `restoreTerminalLayoutOnLaunch` toggle, the clear-layout action, + and a stub `TerminalLayoutPersistenceClient`. + +**Phase B — restore pipeline (#113, assembled in #116).** + +- `TerminalLayoutSnapshotPayload`: a versioned Codable model + (worktrees → tabs → recursive split nodes) with its own validity fuses + (`maxWorktrees`, `maxSplitNodesPerTab`, `maxSplitDepth`) and migration support. +- `TerminalLayoutPersistenceClient` does snapshot I/O (`loadSnapshot` / `saveSnapshot` / + `clearSnapshot`), rejecting invalid or oversized files. +- `WorktreeTerminalManager` orchestrates: build the payload from live terminal states on + save; on restore, match snapshot worktrees against the loaded repository list, rebuild + each `WorktreeTerminalState` tab/split tree, and clear the snapshot if anything fails. +- `LaunchRestoreMode` (`lastFocusedWorktree` vs `restoreLayout`) unifies the two startup + paths in `AppFeature`; restore fires once, when repositories are loaded and the + persistence phase is active. +- Save points: scene phase going inactive/background (async) and + `applicationWillTerminate` (synchronous), the latter because macOS termination does not + wait for async effects. + +## Alternatives & decisions + +- **Snapshot-and-rebuild over a session multiplexer.** The fork restores *layout* and + spawns fresh shells; it never attempted process-level session survival. Upstream later + went the other way, bundling a `zmx` multiplexer for terminal-session persistence + (upstream #334/#356/#357/#360/#361/#368/#369). The 2026-06-09 upstream review batch + deliberately skipped that entire track: the fork keeps its own terminal-layout + persistence and takes no `zmx` dependency. Recorded in the upstream ledger + (`docs-ai/017-upstream-sync-process/upstream-ledger.md`). +- **Kept over upstream's own layout persistence.** Upstream also shipped a + layout-persistence feature (`771e4aab`), reviewed in the 2026-04-08 v0.8.0 batch. The + fork's implementation had already merged a week earlier (#116, 2026-04-01) and was not + replaced. +- **Opt-in, fail-closed.** Because restore runs at boot, every failure path clears the + snapshot and falls back to a clean launch (later also warning via toast, #125), and the + toggle shipped default-off/experimental. It still is today. +- **Font size as a Prowl setting, not a Ghostty config edit** — keeps the user's Ghostty + config file untouched and lets the override travel with Prowl's own settings (#77). + +## Amendments + +- Updated 2026-06-16: two launch-ordering races fixed months later — Default View + restoration hang (#380) and a scenePhase save that cleared the snapshot before restore + could read it (#459) — see [002-launch-restore-races.md](002-launch-restore-races.md) diff --git a/docs-ai/014-terminal-layout-persistence/001-action.md b/docs-ai/014-terminal-layout-persistence/001-action.md new file mode 100644 index 00000000..846c8af0 --- /dev/null +++ b/docs-ai/014-terminal-layout-persistence/001-action.md @@ -0,0 +1,80 @@ +# 014 — Terminal Layout Persistence: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-27 | Font-size override persisted as `GlobalSettings.terminalFontSize`; synced from cell-size callbacks, normalized against the Ghostty default, injected at boot | PR #77 | +| 2026-03-27 | Cmd+0 reset propagated via `GHOSTTY_ACTION_CONFIG_CHANGE` → `onConfigChange` bridge callback so new tabs pick up the cleared override | PR #81 | +| 2026-03-31 | Phase A: cache moved to App Support (+ legacy migration), `PathPolicy`, `snapshotPersistencePhase` write guard, snapshot fuses, `restoreTerminalLayoutOnLaunch` toggle + clear action | PR #112 | +| 2026-04-01 | Phase B: `TerminalLayoutSnapshotPayload`, persistence client I/O, `WorktreeTerminalManager` save/restore orchestration, tab/split tree reconstruction, `AppFeature` lifecycle hooks | PR #113 | +| 2026-04-01 | Umbrella merge to main: both phases + `LaunchRestoreMode`, terminate-time save, Advanced Settings toggle (~700 lines of tests) | PR #116 (closes #76) | +| 2026-04-01 | Plain folders restore via `selectRepository`; "Clear saved terminal layout" gates re-saving through `suppressLayoutSaveUntilRelaunch` | PR #120 → [010-plain-folder-support](../010-plain-folder-support/000-plan.md) | +| 2026-04-01 | Font-size stability across worktree switches: surfaces marked `font_size_adjusted` (via `increase_font_size:0`) so keybind-config reloads don't reset fonts; Cmd+0 freed for `reset_font_size` by dropping tab-0/worktree-0 shortcuts | PR #121 | +| 2026-04-01 | Restore no longer skipped when the boot snapshot matches disk state: `repositoriesLoaded` always emits `repositoriesChanged` on the `.restoring` → `.active` transition | PR #122 | +| 2026-04-02 | Safeguards from #123: restored split ratio clamped to `[0.1, 0.9]`; `layoutRestoreFailed` event → warning toast when a snapshot is reset | PR #125 | +| 2026-04-08 | Tab title and icon persisted in the snapshot (optional fields, backward-compatible decode) | PR #186 → [022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md) | +| 2026-06-01 | Default View launch race + restoration hang fix | PR #380 — see [002-launch-restore-races.md](002-launch-restore-races.md) | +| 2026-06-16 | scenePhase-save-clears-snapshot launch race fix | PR #459 — see [002-launch-restore-races.md](002-launch-restore-races.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift` — + `currentVersion = 2`; fuses `maxWorktrees = 128`, `maxSplitNodesPerTab = 1024`, + `maxSplitDepth = 24`; `SnapshotWorktree` → `SnapshotTab` (`title`, `customTitle`, + `icon`) → recursive `SnapshotSplitNode`. The v1→v2 migration promotes a v1 `title` to + `customTitle` — v2 arrived with persistent custom tab titles + ([022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md)), which split the live + shell title from the user override that #186 had stored in one field. +- `supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift` — load/save/clear + with `maxSnapshotFileBytes` and validity checks; invalid snapshots are deleted. +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — + `persistLayoutSnapshot()` (async, from the `.saveLayoutSnapshot` command) and + `persistLayoutSnapshotSync()` (from `applicationWillTerminate` in + `supacode/App/supacodeApp.swift`); `restoreLayoutSnapshot` emits `.layoutRestored` / + `.layoutRestoreFailed` terminal events and clears the snapshot on failure. +- `supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift` — + per-worktree serialization and tab/split-tree reconstruction; restored split ratios + are clamped to `[0.1, 0.9]` (#125). +- `supacode/Features/App/Models/LaunchRestoreMode.swift` — gained a third case, + `cliOpenPath(String)`, for `prowl open` cold launches + ([013-prowl-cli](../013-prowl-cli/000-plan.md)), beyond the two planned modes. +- `supacode/Features/App/Reducer/AppFeature.swift` — `launchRestoreMode` is derived from + the setting at init and consumed once on `repositoriesChanged`; the + inactive/background save is gated on the setting, `suppressLayoutSaveUntilRelaunch`, + and `launchRestoreMode != .restoreLayout` (#459). +- `supacode/Support/PathPolicy.swift`, `supacode/Support/SupacodePaths.swift` — path + policy and the App Support cache location + (`…/com.onevcat.prowl/cache/terminal-layout-snapshot.json`) with legacy `~/.prowl` + migration. +- `supacode/Features/Settings/Models/GlobalSettings.swift` — + `restoreTerminalLayoutOnLaunch` (still default `false`) and `terminalFontSize`; + the toggle + clear button live in + `supacode/Features/Settings/Views/AdvancedSettingsView.swift`, still labeled + "(experimental)". +- Font size: `WorktreeTerminalManager.preferredFontSize` feeds new surfaces; + `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` still sends + `increase_font_size:0` after creating an overridden surface so config reloads keep it + (#121). + +User-facing behavior is documented in `docs/components/terminal.md` ("Layout +persistence"), `docs/components/settings.md` (Advanced pane), and +`docs/reference/settings-fields.md`. + +## Deviations from plan + +- Phase A's repository-snapshot fuses and `PathPolicy` outgrew this feature: they now + guard the startup cache and worktree cleanup generally, not just layout restore. +- `LaunchRestoreMode` acquired the unplanned `cliOpenPath` case when the `prowl` CLI + needed to suppress worktree restoration on cold `prowl open` launches. +- The snapshot format needed a version bump (v2) once tab identity work separated live + titles from user overrides — #186's single-`title` design turned out to be lossy. +- The launch sequencing around restore proved fragile well after the feature stabilized; + two races surfaced months later (see + [002-launch-restore-races.md](002-launch-restore-races.md)). + +## Open questions + +- The feature has shipped enabled-by-hand since 2026-04-01 yet is still marked + "(experimental)" with default `false`; no recorded decision either promotes or + retires the experimental label. diff --git a/docs-ai/014-terminal-layout-persistence/002-launch-restore-races.md b/docs-ai/014-terminal-layout-persistence/002-launch-restore-races.md new file mode 100644 index 00000000..e83f606e --- /dev/null +++ b/docs-ai/014-terminal-layout-persistence/002-launch-restore-races.md @@ -0,0 +1,42 @@ +# 014 — Amendment: Launch-Ordering Races (2026-06) + +## Context + +Two independent startup races surfaced about two months after the feature stabilized, +both rooted in the same property: layout restore participates in app launch, so anything +else that runs at launch (Default View application, the scene-phase bootstrap) can +observe or destroy restore state before restore has run. + +## Change + +**#380 — Default View launch race and restoration hang (2026-06-01).** With +`restoreTerminalLayoutOnLaunch` enabled but no snapshot on disk, +`WorktreeTerminalManager` never signalled completion and the restoration phase stalled +indefinitely; separately, the Default View (Shelf/Canvas) could be applied before +settings and the snapshot were reliably loaded. Fixes: + +- `WorktreeTerminalManager` emits `.layoutRestored(selectedWorktreeID: nil)` when no + snapshot exists, so the pipeline always terminates. +- Default View application was centralized into `AppFeature.applyDefaultViewMode` and + moved to `repositoriesChanged` (deferred while `launchRestoreMode == .restoreLayout`, + then applied after `.layoutRestored` / `.layoutRestoreFailed`); the redundant early + launch-view logic in `RepositoriesFeature` was removed. + +**#459 — scenePhase save clears the snapshot before restore (2026-06-16).** A +`ContentView.task` bootstrap (added in `73d1b07a`) sends `scenePhaseChanged(.background)` +at launch, before any terminal state exists. The async save path found zero active +states, treated that as "nothing to persist", and *cleared the snapshot file* — so the +subsequent restore found nothing on disk. Fix: the scenePhase-triggered save is skipped +while `launchRestoreMode == .restoreLayout` (i.e. until the pending restore has been +consumed), alongside the existing setting and `suppressLayoutSaveUntilRelaunch` gates. + +## Refs + +- PR #380, PR #459 (fork) + +## Current state + +Both gates are visible in `supacode/Features/App/Reducer/AppFeature.swift` (scene-phase +save condition, `applyDefaultViewMode` call sites in `AppFeature+Support.swift` / +`AppFeature+TerminalEvents.swift`) and the no-snapshot `.layoutRestored(nil)` emission in +`supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`. diff --git a/docs-ai/015-repositories-feature-refactor/000-plan.md b/docs-ai/015-repositories-feature-refactor/000-plan.md new file mode 100644 index 00000000..91c93b5e --- /dev/null +++ b/docs-ai/015-repositories-feature-refactor/000-plan.md @@ -0,0 +1,89 @@ +# 015 — RepositoriesFeature Refactor: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-03 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #131, #403, #426 | +| **Sources** | Fork issue #114, PR descriptions | +| **Related** | [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md), [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md) | + +## Background + +By late March 2026, `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` had +grown past 4,000 lines with roughly 114 action handlers and about 220 `Action` enum cases. +Fork issue #114 recorded two concrete costs: + +1. **TCA `@CasePathable` macro type-check failures.** Adding new cases to the huge `Action` + enum could make the compiler fail with generic "type cannot conform to Reducer" errors, + because the macro expansion exceeded type-check budgets. This was hit for real in PR + #113 when adding a `setLaunchRestoreMode` action — a hard blocker, not just a smell. +2. **Developer velocity.** Every change required understanding the whole file, and + incremental build times suffered. + +The issue was filed as "not urgent, but should be done before the next round of feature +work that needs to add actions to RepositoriesFeature". + +## Goals + +- Split the monolithic `Reduce` body into focused sub-reducers so the `Action` enum and + handler logic are grouped by domain. +- Keep `RepositoriesFeature.State` flat — no state re-nesting. +- Minimize call-site churn during the transition. +- Preserve behavior; existing `RepositoriesFeatureTests` must keep passing. + +**Non-goals** + +- No behavior or UX changes; this is a pure decomposition. +- No extraction of state into child feature states (`Scope`-per-child was considered as a + mechanism, but slicing `State` was not a goal). + +## Design / Approach + +Issue #114 proposed candidate groupings based on the observed action-handler clusters: + +| Group | Actions (examples) | +| --- | --- | +| Worktree creation/deletion/archive | `createRandomWorktree`, `archiveWorktree*`, `deleteWorktree*`, `worktreeCreationPrompt` | +| Pull request actions | `pullRequestAction`, merge/close/checkout flows | +| GitHub integration | `refreshGithubIntegration*`, `repositoryPullRequestsLoaded` | +| Pin/reorder | `pinWorktree`, `unpinWorktree`, `*Moved` | +| Repository management | `requestRemoveRepository`, `repositoryRemoved`, `openRepositories` | + +Each sub-reducer owns a slice of the `Action` enum (as a nested namespaced action enum) +plus the corresponding handler logic; the ~40 private helper functions move alongside +their consumers. The implementation (PR #131) realized this as grouped action cases — +`Action.worktreeCreation(...)`, `.worktreeLifecycle(...)`, `.worktreeOrdering(...)`, +`.githubIntegration(...)`, `.repositoryManagement(...)` — reduced by dedicated reducer +helpers composed with `CombineReducers`, while `State` stays flat. + +Two later code-health waves belong to the same thread: + +- PR #403 (2026-06-07) extended the file-splitting discipline repo-wide (non-test sources + under 1,000 lines), which for this feature extracted the remaining core reducer, loading, + selection, and state-query logic into dedicated extension files. +- PR #426 (2026-06-08) cleaned up the worktree-creation dependency surface by introducing + a `GitWorktreeCreateRequest` value instead of long positional parameter lists on + `GitClient.createWorktreeStream`. + +## Alternatives & decisions + +- **Compatibility shims: temporary only.** PR #131 initially kept static forwarding + constructors on `RepositoriesFeature.Action` (old flat case names constructing the new + grouped cases) to limit call-site churn — then, still within the same PR, migrated all + call sites and tests to the grouped syntax and deleted the ~300 lines of shims. The + merged result carries no compatibility layer. +- **Grouped cases over `Scope` children.** The issue floated `Scope` composition; the + implementation chose `CombineReducers` over a shared flat `State` with namespaced action + groups, avoiding state re-nesting and keeping views/tests addressing one feature. +- **PR actions folded into GitHub integration.** The plan's separate "pull request + actions" group ended up inside the `githubIntegration` group (handled in + `RepositoriesFeature+GithubIntegration.swift`) rather than as its own sub-reducer. + +## Amendments + +- Updated 2026-06-07: repo-wide large-file split adds five more RepositoriesFeature + extension files (PR #403) — see [002-split-large-swift-files.md](002-split-large-swift-files.md) +- Updated 2026-06-08: `GitWorktreeCreateRequest` groups worktree stream creation inputs + (PR #426) — see [003-worktree-stream-request-api.md](003-worktree-stream-request-api.md) diff --git a/docs-ai/015-repositories-feature-refactor/001-action.md b/docs-ai/015-repositories-feature-refactor/001-action.md new file mode 100644 index 00000000..0da4dfe7 --- /dev/null +++ b/docs-ai/015-repositories-feature-refactor/001-action.md @@ -0,0 +1,61 @@ +# 015 — RepositoriesFeature Refactor: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-31 | Fork issue filed: 4,000+ line reducer, `@CasePathable` macro type-check blocker | issue #114 | +| 2026-04-03 | Action enum split into grouped cases; handler logic moved to sub-reducer helpers (initially behind compatibility shims) | PR #131 (`33bc7b79`) | +| 2026-04-03 | Same PR: all call sites/tests migrated to grouped syntax; ~300 lines of shims removed; sub-reducers extracted into dedicated files; `CancelID` moved into the struct | PR #131 (`cb7e441a`, `292546eb`, `c00bd5f4`) | +| 2026-06-07 | Repo-wide large-file split; RepositoriesFeature gains `+CoreReducer`, `+RepositoryLoading`, `+Selection`, `+StateQueries`, `+WorktreeState` extensions | PR #403 — see [002](002-split-large-swift-files.md) | +| 2026-06-08 | `GitWorktreeCreateRequest` replaces positional parameters on `createWorktreeStream` | PR #426 — see [003](003-worktree-stream-request-api.md) | + +PR #131 touched 27 files (+2,952/−2,693); `RepositoriesFeature.swift` alone dropped by +roughly 2,500 lines, with the logic landing in five new extension files +(`+WorktreeCreation` 619, `+WorktreeLifecycle` 505, `+GithubIntegration` 551, +`+RepositoryManagement` 222, `+WorktreeOrdering` 186 lines at merge time). + +## Outcome & current state (as of 2026-07-12) + +`supacode/Features/Repositories/Reducer/` is now a directory of focused files: + +- `RepositoriesFeature.swift` (~580 lines) — `State`, the grouped `Action` enum, and a + `body` that composes `CombineReducers { Reduce(reduceCore); worktreeCreationReducer; + worktreeLifecycleReducer; worktreeOrderingReducer; githubIntegrationReducer; + repositoryManagementReducer; workspaceCreationReducer; Scope(\.activeAgents, ...) }` + plus `.ifLet` presentation reducers for the worktree/workspace creation prompts. +- Grouped action cases in the current tree: `.worktreeCreation`, `.worktreeLifecycle`, + `.worktreeOrdering`, `.githubIntegration`, `.repositoryManagement`, and + `.workspaceCreation` — the last added by later workspace/plain-folder work + (see [010-plain-folder-support](../010-plain-folder-support/000-plan.md)), which reused + the pattern established here. +- Sub-reducer extension files from #131: `RepositoriesFeature+WorktreeCreation.swift`, + `+WorktreeLifecycle.swift`, `+WorktreeOrdering.swift`, `+GithubIntegration.swift`, + `+RepositoryManagement.swift`. +- Further extensions from #403: `+CoreReducer.swift` (the `reduceCore` catch-all switch), + `+RepositoryLoading.swift`, `+Selection.swift`, `+StateQueries.swift`, + `+WorktreeState.swift`. +- Later feature work added `+WorkspaceChildren.swift`, `+WorkspaceCreation.swift`, and + `WorkspaceCreationPromptFeature.swift` alongside `WorktreeCreationPromptFeature.swift` + (not part of this refactor, but shaped by its layout). + +The worktree stream request API from #426 is current: `GitWorktreeCreateRequest` is +defined in `supacode/Clients/Git/GitClientTypes.swift` and consumed by +`GitClient.createWorktreeStream(_:)` (`supacode/Clients/Git/GitClient.swift`), the TCA +dependency in `supacode/Clients/Repositories/GitClientDependency.swift`, and the reducer +in `RepositoriesFeature+WorktreeCreation.swift`. + +## Deviations from plan + +- The plan's "pull request actions" candidate group was merged into the GitHub + integration group instead of becoming a sixth sub-reducer; `pullRequestAction` is + handled in `RepositoriesFeature+GithubIntegration.swift`. +- The compatibility-shim strategy described in the PR summary was superseded within the + same PR: shims were added and then fully removed before merge, so no transitional API + ever shipped. + +## Open questions + +- `RepositoriesFeature+GithubIntegration.swift` is currently ~1,027 lines, slightly above + the 1,000-line ceiling that PR #403 set for non-test sources — later PR-status feature + growth has re-crossed the threshold this refactor established. diff --git a/docs-ai/015-repositories-feature-refactor/002-split-large-swift-files.md b/docs-ai/015-repositories-feature-refactor/002-split-large-swift-files.md new file mode 100644 index 00000000..8f65b73a --- /dev/null +++ b/docs-ai/015-repositories-feature-refactor/002-split-large-swift-files.md @@ -0,0 +1,41 @@ +# 015 — Amendment: Repo-wide Large Swift File Split (PR #403) + +## Context + +Two months of feature work after the #131 decomposition, several other source files +had grown to the same unmanageable size the original `RepositoriesFeature.swift` once had +(`WorktreeTerminalState.swift` ~1,850 lines, `GhosttySurfaceView.swift` ~1,900 lines, +`RepositoriesFeature.swift` itself back near 2,500 lines of remaining core logic). PR #403 +(merged 2026-06-07) applied the same discipline repo-wide: non-test sources stay under +1,000 lines, split along behavior-preserving extension/helper boundaries. + +## Change + +50 files changed (+11,822/−11,466); pure code motion plus lint cleanup of the +`AppFeature` helper switches the split introduced. The major splits: + +| Area | Extracted files | +| --- | --- | +| Repositories reducer | `RepositoriesFeature+CoreReducer.swift`, `+RepositoryLoading.swift`, `+Selection.swift`, `+StateQueries.swift`, `+WorktreeState.swift` | +| App reducer | `AppFeature+CommandPalette.swift`, `+Support.swift`, `+TerminalEvents.swift` | +| Command palette | `CommandPaletteFuzzyScorer.swift`, `CommandPaletteSupport.swift` | +| Terminal state | `WorktreeTerminalState+AgentDetection.swift`, `+CLI.swift`, `+LayoutSnapshot.swift`, `+Notifications.swift`, `+Surfaces.swift`, `+TabIcons.swift` | +| Ghostty runtime/surface | `GhosttyRuntime+Callbacks.swift`, `+ThemeFallback.swift`, `GhosttyRuntimeSupport.swift`, `GhosttySurfaceView+Accessibility/…/+TextInput.swift`, `GhosttySurfaceScrollView.swift`, `CLIKeySpec.swift` | +| Canvas | `CanvasSupportViews.swift`, `CanvasView+Focus.swift` | +| Settings | `RepositorySettingsCustomCommandsView.swift`, `RepositorySettingsSupportingViews.swift` | +| Git/GitHub clients | `GitClientShellHelpers.swift`, `GitClientTypes.swift`, `GithubCLIExecutableResolver.swift`, `GithubCLIModels.swift` | + +For this entry's feature specifically, `RepositoriesFeature.swift` shed another ~2,460 +lines: the residual root switch became `reduceCore` in +`RepositoriesFeature+CoreReducer.swift`, and loading/selection/state-query helpers moved +into their own extensions. + +## Refs + +- PR #403 (merge `50327959`, 2026-06-07) + +## Current state + +All extracted files listed above exist in the working tree. The extraction of +`GitClientTypes.swift` in this PR is what gave #426 its landing spot for +`GitWorktreeCreateRequest` the following day. diff --git a/docs-ai/015-repositories-feature-refactor/003-worktree-stream-request-api.md b/docs-ai/015-repositories-feature-refactor/003-worktree-stream-request-api.md new file mode 100644 index 00000000..e5f90506 --- /dev/null +++ b/docs-ai/015-repositories-feature-refactor/003-worktree-stream-request-api.md @@ -0,0 +1,36 @@ +# 015 — Amendment: Worktree Stream Request API (PR #426) + +## Context + +`GitClient.createWorktreeStream` and its TCA dependency wrapper had accumulated a long +positional parameter list (repo root, base directory, name, copy flags, base ref, +directory override — the last added by the name/parent-dir override work, #424). Mock +signatures in tests had to unpack the same long tuple, making call sites fragile every +time worktree creation gained an option. + +## Change + +- Added `GitWorktreeCreateRequest` (`nonisolated struct`, `Equatable`, `Sendable`) in + `supacode/Clients/Git/GitClientTypes.swift`, grouping all worktree stream creation + inputs. +- `GitClient.createWorktreeStream(_:)` (`supacode/Clients/Git/GitClient.swift`) and the + TCA git dependency (`supacode/Clients/Repositories/GitClientDependency.swift`) now take + the request value instead of positional parameters. +- Reducer call sites in `RepositoriesFeature+WorktreeCreation.swift` and tests + (`GitClientCreateWorktreeStreamTests`, `RepositoriesFeatureTests`) inspect request + fields instead of unpacking mock closure arguments. + +Behavior-preserving; 6 files changed (+107/−85). + +## Refs + +- PR #426 (merge `87b0fbd9`, 2026-06-08) +- Cross-link: worktree creation option evolution lives in + [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md) + +## Current state + +Verified in the working tree: `GitWorktreeCreateRequest` is defined at +`supacode/Clients/Git/GitClientTypes.swift` and remains the sole input to +`createWorktreeStream` across `GitClient.swift`, `GitClientDependency.swift`, and +`RepositoriesFeature+WorktreeCreation.swift`. diff --git a/docs-ai/016-dev-build-and-ci-workflow/000-plan.md b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md new file mode 100644 index 00000000..1466c4f7 --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md @@ -0,0 +1,93 @@ +# 016 — Dev Build & CI Workflow: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-04 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #140, #142, #144, #145, #152 (anchor wave); later waves: #248/#503, #266/#269/#307/#308/#333, #391/#461/#479/#482 | +| **Sources** | PR descriptions, commits `1028d5b3` (#140) and `72a3dd2e` (hook revert), current `Makefile` / `.github/` tree | +| **Related** | [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md), [013-prowl-cli](../013-prowl-cli/000-plan.md), [041-ghosttykit-prebuilt-artifacts](../041-ghosttykit-prebuilt-artifacts/000-plan.md), `CLAUDE.md` (build command reference) | + +## Background + +Prowl builds against `Frameworks/GhosttyKit.xcframework`, compiled from the Zig source in +the `ThirdParty/ghostty` submodule, and embeds the SwiftPM-built `prowl` CLI into the app +bundle. By early April 2026 the day-to-day developer workflow had several recurring pains: + +- Switching branches that pinned a different Ghostty submodule SHA left stale local + artifacts in place, producing compile-time symbol drift (e.g. missing + `ghostty_config_load_file`) that had to be diagnosed by hand. +- Conversely, CI rebuilt Ghostty (~10 minutes) inside `make build-app` even when the + artifact cache had hit, because the freshness markers were not part of the cache. +- `make test` printed raw `xcodebuild` output — noisy for humans and expensive for coding + agents; CI test failures gave no actionable detail (fork issue #103). +- `make build-app` (Debug) depended on the release-mode CLI build (`swift build -c release`), + a Debug/Release mismatch that slowed every dev iteration. + +This entry covers the tooling that fixed these, and the three later waves that kept +extending the same surface (Makefile, `.github/workflows/test.yml`, +`.github/actions/setup-macos/action.yml`, project build settings) — formatter/lint +alignment, CI throughput/caching, and Debug-app identity + local dev-loop speed. + +## Goals + +- Keep local GhosttyKit artifacts automatically in sync with the pinned submodule SHA; + rebuild only when the SHA changes or artifacts are missing. +- Make the CI Ghostty cache actually short-circuit the rebuild. +- Structured, agent-friendly build/test output (xcsift TOON) plus actionable failure + details extracted from the `.xcresult` bundle, locally and as a CI artifact. +- Embed a debug-mode CLI in Debug app builds; keep release CLI for distribution builds. +- Later waves (see Amendments): idempotent formatting tooling, faster and non-lying CI, + stable Debug app identity, and a fast `make run-app` loop. + +## Design / Approach + +Anchor wave, all landed 2026-04-04/05: + +1. **GhosttyKit auto-sync by submodule SHA** (#140). `make ensure-ghostty` compares + `git rev-parse HEAD:ThirdParty/ghostty` against the last-synced SHA persisted in + `.ghostty_hash`; a build-stamp file (`.ghostty_build_stamp`) plus make prerequisites + gate the actual rebuild. `build-app` and `test` route through `ensure-ghostty`, and a + SHA change also clears `supacode-*` DerivedData (Ghostty header/module changes). + `make sync-ghostty` remains as the explicit force-rebuild path. +2. **CI cache alignment** (#142). Include `.ghostty_hash` and `.ghostty_build_stamp` in + the Ghostty cache payload (namespace bumped to `ghostty-v1`), and always refresh the + marker files after restore/build so the `ensure-ghostty` fast path works on clean + runners. +3. **Structured test output + failure details** (#144, fixing fork issue #103). `make test` + pipes `xcodebuild test` through `xcsift --format toon`, persists the result bundle at + `build/test-results/supacode-tests.xcresult`, preserves the real exit code via + `PIPESTATUS[0]`, and on failure runs `scripts/print-xcresult-failures.sh` to print test + name/identifier/failure text. CI uploads the `.xcresult` as an artifact on failure. +4. **Warning/debt cleanup** (#145). Remove local test-compiler warnings; opt CI JavaScript + actions into the Node 24 runtime (`FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`). +5. **Debug CLI for dev builds** (#152). New `embed-cli-debug` target builds the CLI with + plain `swift build` and copies it to `Resources/prowl-cli/prowl`; `build-app` uses it, + while `archive` (Release) keeps the universal release `embed-cli`. + +## Alternatives & decisions + +- **Repo-managed git hooks — shipped, then dropped same day.** #140 added + `.githooks/post-checkout` / `.githooks/post-merge` and `make setup-local-hooks` to + auto-run `ensure-ghostty` after branch/merge changes. Commit `72a3dd2e` ("keep global + hooks and drop repo hook override") removed them hours later: overriding the user's + global `core.hooksPath` was judged too invasive. Freshness relies on `build-app`/`test` + always passing through `ensure-ghostty` instead. +- **Marker files over make-only dependency tracking**: the SHA hash file makes the fast + path survive CI cache restores and DerivedData wipes, which pure make timestamps do not. +- **Debug/Release CLI split** (#152): distribution builds intentionally keep the + release-mode universal binary; only the dev loop got the debug CLI. +- **xcsift TOON as the output format** for all xcodebuild invocations (build, test, + archive), managed via mise (`github:ldomaradzki/xcsift` in `mise.toml`). + +## Amendments + +- Updated 2026-04-29 (+ 2026-06-24): swift-format ↔ SwiftLint trailing-comma alignment; + `make lint` became a pure check — see [002-format-lint-alignment.md](002-format-lint-alignment.md) +- Updated 2026-05-24: CI throughput & caching wave — workflow concurrency, SPM cache out + of `/tmp`, parallel test steps + failure-masking fix, compilation-cache key fix + + type-checker hotspots — see [003-ci-throughput-and-caching.md](003-ci-throughput-and-caching.md) +- Updated 2026-06-20: Debug app identity at project level + local dev-loop acceleration + (`run-app` guard removal, content-aware build inputs, build-settings cache, incremental + compilation) — see [004-debug-identity-and-dev-loop.md](004-debug-identity-and-dev-loop.md) diff --git a/docs-ai/016-dev-build-and-ci-workflow/001-action.md b/docs-ai/016-dev-build-and-ci-workflow/001-action.md new file mode 100644 index 00000000..e1b1fa5e --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/001-action.md @@ -0,0 +1,93 @@ +# 016 — Dev Build & CI Workflow: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-04 | `make ensure-ghostty`: auto-sync GhosttyKit by submodule SHA (`.ghostty_hash` + `.ghostty_build_stamp`), DerivedData clear on SHA change, `sync-ghostty` force path; repo-managed git hooks | PR #140 | +| 2026-04-04 | Repo hook override reverted same day (`.githooks/`, `setup-local-hooks` removed); global hooks respected | commit `72a3dd2e` | +| 2026-04-04 | CI Ghostty cache includes marker files, `ghostty-v1` namespace, markers refreshed after restore so `ensure-ghostty` fast-paths on clean runners | PR #142 | +| 2026-04-04 | `make test` via xcsift TOON, persisted `.xcresult`, exit code via `PIPESTATUS[0]`, `scripts/print-xcresult-failures.sh` on failure, CI xcresult artifact upload | PR #144 | +| 2026-04-04 | Local test-warning cleanup; CI JavaScript actions on Node 24 runtime | PR #145 | +| 2026-04-05 | `embed-cli-debug` target: Debug app builds embed a debug-mode CLI; release `embed-cli` kept for `archive` | PR #152 | +| 2026-04-29 | swift-format ↔ SwiftLint trailing-comma conflict resolved; `make lint` became a pure check | PR #248 — see [002](002-format-lint-alignment.md) | +| 2026-05-08 | Superseded test workflow runs canceled via `concurrency` group | PR #266 — see [003](003-ci-throughput-and-caching.md) | +| 2026-05-09 | SPM cache moved from `/tmp` to `~/Library/Caches` to survive macOS `tmp_cleaner` | PR #269 — see [003](003-ci-throughput-and-caching.md) | +| 2026-05-19 | CI test steps parallelized after `build-app`; `test-app` target split out of `test` | PR #307 — see [003](003-ci-throughput-and-caching.md) | +| 2026-05-19 | Xcode compilation-cache CI key fixed (static `-v0` → content-keyed `-v1`); type-checker hotspot refactors; `COMPILATION_CACHE_ENABLE_CACHING`/`EAGER_LINKING` for Debug | PR #308 — see [003](003-ci-throughput-and-caching.md) | +| 2026-05-24 | Parallel test step no longer masks failures (`run_task` bash exit-status bug) | PR #333 — see [003](003-ci-throughput-and-caching.md) | +| 2026-06-05 | `make run-app` guard removed; debug run allowed alongside an existing Prowl instance | PR #391 — see [004](004-debug-identity-and-dev-loop.md) | +| 2026-06-14 | `ensure-ghostty` fronted by pinned prebuilt-artifact download; local Zig build becomes the fallback | PR #450 — see [041](../041-ghosttykit-prebuilt-artifacts/000-plan.md) | +| 2026-06-17 | `build-app` inputs made content-aware (`ProwlVersion.swift` sync, CLI embed copy) to stop rebuild churn | PR #461 — see [004](004-debug-identity-and-dev-loop.md) | +| 2026-06-19 | Stable Debug identity (`Prowl Debug` / `com.onevcat.prowl.debug`) at Xcode project level; `install-dev-build` back to plain `ditto` | PR #479 — see [004](004-debug-identity-and-dev-loop.md) | +| 2026-06-20 | `run-app` accelerated: `-showBuildSettings` cache + `SWIFT_COMPILATION_MODE=incremental` for Debug | PR #482 — see [004](004-debug-identity-and-dev-loop.md) | +| 2026-06-24 | Trailing-comma lint violations on `main` fixed (`make check` red while CI green) | PR #503 — see [002](002-format-lint-alignment.md) | + +## Outcome & current state (as of 2026-07-12) + +- **`Makefile`**: `ensure-ghostty` first runs `scripts/ensure-ghosttykit-artifacts.sh` + (prebuilt download, entry 041); exit code 2 falls back to the #140 source-build path — + compare `HEAD:ThirdParty/ghostty` against `.ghostty_hash`, `$(MAKE) -B + build-ghostty-xcframework`, clear `supacode-*` DerivedData on SHA change. + `sync-ghostty` and `_record-ghostty-hash` survive as designed. `build-app` depends on + `ensure-ghostty embed-cli-debug embed-docs` and pipes xcodebuild through + `mise exec -- xcsift -w --format toon` with `SWIFT_COMPILATION_MODE=incremental`. +- **Test targets**: `test` is now `ensure-ghostty embed-cli-debug embed-docs test-app` + (#307 split); `test-app` keeps the #144 shape — result bundle at + `build/test-results/supacode-tests.xcresult`, `PIPESTATUS[0]` exit-code preservation, + `scripts/print-xcresult-failures.sh` on failure. `test-cli-smoke` / `test-cli-integration` + are separate SwiftPM-based targets (entry 013). +- **CLI embedding**: `embed-cli-debug` is a file rule on `Resources/prowl-cli/prowl` with + `CLI_SOURCE_INPUTS` prerequisites and a `cmp -s` copy guard (#152 + #461); + `sync-cli-version` only rewrites `supacode/CLIService/Shared/ProwlVersion.swift` when the + version actually changed (#461). `archive` still uses the release universal `embed-cli`. +- **CI workflow** (`.github/workflows/test.yml`): `concurrency` group with + `cancel-in-progress` (#266); `make lint` → `make build-app` → a parallel step running + `make test-app`, `make test-cli-smoke`, `make test-cli-integration` via `run_task` + with the #333 exit-status capture (the fix is documented in an inline comment); + xcresult artifact upload on failure (#144); `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` (#145). +- **CI caches** (`.github/actions/setup-macos/action.yml`): mise cache; Ghostty cache + keyed `ghostty-v1-<submodule SHA>` including both marker files, plus an unconditional + "Sync ghostty marker files" step (#142); SPM cache at + `~/Library/Caches/supacode-spm-cache/SourcePackages` (#269, matching `SPM_CACHE_DIR` in + the Makefile); Xcode compilation cache keyed + `xcode-compilation-cache-v1-${{ hashFiles('Package.resolved', 'supacode.xcodeproj/project.pbxproj') }}` + with `restore-keys` fallback (#308). +- **Project settings** (`supacode.xcodeproj/project.pbxproj`): Debug configurations carry + `PRODUCT_NAME = "Prowl Debug"`, `PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl.debug`, + `ENABLE_DEBUG_DYLIB = NO`, `INFOPLIST_KEY_CFBundleDisplayName = "Prowl Debug"`, and the + test target's Debug `TEST_HOST` points at `Prowl Debug.app` (#479); + `COMPILATION_CACHE_ENABLE_CACHING = YES` at project- and target-Debug level (Release + target sets `NO`) and `EAGER_LINKING = YES` for Debug (#308). +- **Dev loop**: `run-app` / `install-dev-build` read xcodebuild settings from + `.build_settings_cache.json`, invalidated by `project.pbxproj` mtime (#482); + `install-dev-build` is a guarded plain `ditto` copy with no re-signing (#479); `run-app` + launches the Debug executable directly with no running-instance guard (#391). + `.gitignore` covers `.ghostty_hash`, `.ghostty_build_stamp`, `.build_settings_cache.json`. +- **Formatting/lint**: see [002](002-format-lint-alignment.md); `check` = + `format-changed format-lint lint`, `lint` is check-only, `.swiftlint.yml` disables + `trailing_comma`. + +## Deviations from plan + +- The #140 repo-managed git hooks (`.githooks/`, `make setup-local-hooks`) were removed + the same day (commit `72a3dd2e`); nothing hook-based remains. +- The SHA-compare fast path is no longer the first line of `ensure-ghostty`: since #450 + (entry 041) the pinned prebuilt-artifact download runs first and the #140 logic is the + fallback for unpinned SHAs/download failures. +- `make test` no longer runs CLI tests implicitly and delegates the xcodebuild invocation + to the `test-app` sub-target (#307); CI runs the three test suites in parallel instead + of a single serial `make test`. + +## Open questions + +- CI still runs only `make lint`, not `make format-lint`, so `main` can again turn red for + `make check` while CI stays green — exactly the gap #503 patched around rather than + closed. +- The Ghostty cache payload still includes `.ghostty_hash` / `.ghostty_build_stamp` (#142), + but the later unconditional "Sync ghostty marker files" step rewrites both after every + restore, making the cached copies redundant — harmless leftover, never cleaned up. +- The `.build_settings_cache.json` invalidation (#482) only watches `project.pbxproj` + mtime; an Xcode upgrade or DerivedData relocation that changes `BUILT_PRODUCTS_DIR` + without touching the project file would keep serving stale paths until the cache file is + deleted by hand. diff --git a/docs-ai/016-dev-build-and-ci-workflow/002-format-lint-alignment.md b/docs-ai/016-dev-build-and-ci-workflow/002-format-lint-alignment.md new file mode 100644 index 00000000..4316efbd --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/002-format-lint-alignment.md @@ -0,0 +1,52 @@ +# 016 — Amendment: swift-format ↔ SwiftLint Trailing-Comma Alignment (2026-04-29, 2026-06-24) + +## Context + +Every `make check` run dragged a flood of unrelated reformatting into the diff. The cause +was a genuine rule conflict between the two formatters: + +- `.swiftlint.yml` set `trailing_comma: mandatory_comma: true`, and `make lint` ran + `swiftlint --fix`, which strictly **adds** trailing commas to multi-line collection + literals. +- swift-format 602 (the pinned local version / Swift 6.2 toolchain) actively **removes** + trailing commas in multi-line collection literals whose last element is a multi-line + function call; the `multilineTrailingCommaBehavior: alwaysUsed` knob that would change + this only exists in swift-format 603+. + +The two tools therefore oscillated forever: `swiftlint --fix` produced state A (commas), +any subsequent `swift-format` run flipped back to state B (no commas). Upstream supacode +had already untangled the same conflict (upstream commit `6dec82f2` "Align trailing comma +tooling"), but that commit never reached the fork. + +## Change + +**PR #248 (2026-04-29)** — mirror the no-toolchain-bump portion of upstream's fix: + +- `.swiftlint.yml`: add `trailing_comma` to `disabled_rules`, remove the + `mandatory_comma` block. swift-format becomes the single authority on trailing commas. +- `Makefile`: drop `swiftlint --fix` from the `lint` target — lint is a pure check; + formatting belongs to `make format` / `make format-changed` (swift-format). +- Deliberately **not** adopting `multilineTrailingCommaBehavior: alwaysUsed`: swift-format + 602 silently ignores the unknown key, so the project standardizes on swift-format 602's + natural fixpoint. To be revisited if swift-format is upgraded to ≥603. +- Verified idempotent at merge time: the full-tree sweep changed 0 files, so no bulk + formatting commit was needed. + +**PR #503 (2026-06-24)** — follow-up symptom of the remaining CI gap: two single-line +collection literals with trailing commas landed on `main` and made the full-tree +`swift-format lint --strict` step of `make check` fail locally, while CI stayed green +because the workflow only runs `make lint` (SwiftLint), not `make format-lint`. The PR +fixed the two violations; the CI gap itself was left open. + +## Refs + +- PRs #248, #503; upstream commit `6dec82f2` (provenance). +- `CLAUDE.md` records the resulting convention: "swift-format is the source of truth for + trailing commas". + +## Current state + +As of 2026-07-12: `.swiftlint.yml` lists `trailing_comma` under `disabled_rules`; the +`Makefile` `lint` target is `swiftlint lint --quiet` (no `--fix`), `check` chains +`format-changed format-lint lint`, and `.github/workflows/test.yml` still runs only +`make lint` — the format-lint-in-CI gap noted in [001-action.md](001-action.md) remains. diff --git a/docs-ai/016-dev-build-and-ci-workflow/003-ci-throughput-and-caching.md b/docs-ai/016-dev-build-and-ci-workflow/003-ci-throughput-and-caching.md new file mode 100644 index 00000000..7d860526 --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/003-ci-throughput-and-caching.md @@ -0,0 +1,68 @@ +# 016 — Amendment: CI Throughput & Caching Wave (2026-05-08 → 2026-05-24) + +## Context + +Through May 2026 the CI test workflow was slow and, in one case, dishonest: + +- Rapid pushes to a PR queued redundant full runs. +- Local builds intermittently failed with "package manifest cannot be accessed": the SPM + cache lived under `/tmp/supacode-spm-cache`, and macOS's daily `tmp_cleaner` + (`-atime/-mtime/-ctime +3` rules) deleted per-checkout `Package.swift` files whose + access time never refreshed after resolve — even on machines used daily. +- CLI smoke + integration tests ran serially after app tests, adding a ~40–50 s tail. +- The Xcode compilation cache (`CompilationCache.noindex`) was cached under a static + `xcode-compilation-cache-v0` key; `actions/cache@v4` never re-saves on an exact key hit, + so CI was frozen on the very first ~152 MB snapshot and cold-compiled almost everything. +- After parallelization, a bash pitfall made the parallel step report success even when a + test target failed to compile (a broken PR merged green). + +## Change + +- **PR #266 (2026-05-08)** — add a `concurrency` group + (`${{ github.workflow }}-${{ github.ref }}`, `cancel-in-progress: true`) to + `test.yml` so superseded PR/main runs are canceled. Upstream's + release-tip/warm-cache/inspect-dependencies workflow changes were deliberately skipped + (absent or divergent in the fork). +- **PR #269 (2026-05-09)** — move `SPM_CACHE_DIR` to + `~/Library/Caches/supacode-spm-cache/SourcePackages` (standard cache location, + outside `tmp_cleaner`'s reach) and align the `actions/cache` path in + `.github/actions/setup-macos/action.yml`. +- **PR #307 (2026-05-19)** — keep the explicit `make build-app` CI step; split a + `test-app` target (app/unit tests only) out of `make test`; run `test-app`, + `test-cli-smoke`, `test-cli-integration` concurrently after the build. `make test` + stays self-contained locally by embedding the debug CLI before `test-app`. + `ensure-ghostty` made to fail immediately when the GhosttyKit rebuild fails. + (An earlier attempt that removed the standalone build step saved nothing — `make test` + absorbed the full build cost — so only the independent tails were parallelized.) +- **PR #308 (2026-05-19)** — two levers for Debug build wall time: + 1. Compilation-cache key rotated to + `xcode-compilation-cache-v1-${{ hashFiles('Package.resolved', 'supacode.xcodeproj/project.pbxproj') }}` + so the cache refreshes when its content profile shifts, while routine code-only PRs + hit the primary key and skip the ~30 s re-upload; `restore-keys` keeps older + snapshots reachable. + 2. Type-checker hotspots found with `-warn-long-function-bodies` / + `-warn-long-expression-type-checking`: replace + `Dictionary(uniqueKeysWithValues: map)` with typed loops, promote an inline tuple to + the nominal `ArchivedWorktreeGroup` struct, extract oversized SwiftUI bodies + (`ArchivedWorktreesDetailView.body` 4312 ms → 346 ms). Plus + `COMPILATION_CACHE_ENABLE_CACHING = YES` at the project Debug level and + `EAGER_LINKING = YES` for Debug. +- **PR #333 (2026-05-24)** — the parallel step's `run_task` read `$?` after an + `if cmd; then …; fi` block, which yields the `if` statement's own exit code (`0` when + the condition fails with no `else`), so every task returned success. Fixed by capturing + directly with `"$@" >"$log" 2>&1 || status=$?`. Only the parallel step was affected; + `make build-app` already ran under `bash -o pipefail`. + +## Refs + +- PRs #266, #269, #307, #308, #333. +- `.github/workflows/test.yml`, `.github/actions/setup-macos/action.yml`, `Makefile` + (`SPM_CACHE_DIR`, `test-app`), `supacode.xcodeproj/project.pbxproj`, + `supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift` + (`ArchivedWorktreeGroup`). + +## Current state + +All five changes are live as of 2026-07-12; the exit-status fix is preserved with an +explanatory inline comment in `test.yml`. See [001-action.md](001-action.md) +"Outcome & current state" for the file-level inventory. diff --git a/docs-ai/016-dev-build-and-ci-workflow/004-debug-identity-and-dev-loop.md b/docs-ai/016-dev-build-and-ci-workflow/004-debug-identity-and-dev-loop.md new file mode 100644 index 00000000..1895b842 --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/004-debug-identity-and-dev-loop.md @@ -0,0 +1,53 @@ +# 016 — Amendment: Debug App Identity & Dev-Loop Acceleration (2026-06-05 → 2026-06-20) + +## Context + +By June 2026 the remaining friction was in the local edit-build-run loop: + +- `make run-app` refused to launch when another Prowl instance was running, blocking the + common "run a Debug build next to the installed Release app" workflow. +- Debug builds shared the Release identity, so every reinstall re-triggered macOS TCC + permission prompts (fork issue #464); an earlier fix (#465) patched the installed app's + Info.plist with PlistBuddy and re-signed it, which stripped entitlements/hardened-runtime + flags and only helped `install-dev-build`, not `run-app`. +- `make build-app` rewrote its own inputs on every run (`ProwlVersion.swift` regenerated, + CLI binary recopied), invalidating xcodebuild's incremental state for no reason. +- `xcodebuild -showBuildSettings -json` took 8–63 s per `run-app`/`install-dev-build` + invocation, and Debug builds used `wholemodule` compilation. + +## Change + +- **PR #391 (2026-06-05)** — remove the running-instance guard from `run-app`; the Debug + build path resolution and direct executable launch stay unchanged. +- **PR #461 (2026-06-17)** — make `build-app` inputs content-aware: `sync-cli-version` + writes `supacode/CLIService/Shared/ProwlVersion.swift` only when the version actually + differs (`cmp -s` against a temp render), and the debug CLI copy to + `Resources/prowl-cli/prowl` happens only when the built binary changed. No build stamp + or xcodebuild skip was introduced — `build-app` remains a direct verification path. +- **PR #479 (2026-06-19, supersedes #465)** — move the Debug identity into the Xcode + project's Debug configuration: `PRODUCT_NAME = "Prowl Debug"`, + `PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl.debug`, + `INFOPLIST_KEY_CFBundleDisplayName`, `ENABLE_DEBUG_DYLIB = NO`, and a matching Debug + `TEST_HOST`. The build output is natively correct, so `install-dev-build` returns to a + plain `ditto` copy with no signing-identity discovery or re-signing; both `run-app` and + `install-dev-build` benefit. Release configuration untouched. +- **PR #482 (2026-06-20)** — cache `xcodebuild -showBuildSettings -json` output in + `.build_settings_cache.json`, invalidated by comparing mtime against + `supacode.xcodeproj/project.pbxproj` (measured 62.9 s → 0.7 s); switch Debug builds to + `SWIFT_COMPILATION_MODE=incremental` in `build-app`/`test-app` (no-change rebuild + 127 s → 64 s on the benchmark machine). Release/archive keep `wholemodule`. + +## Refs + +- PRs #391, #461, #479, #482; fork issues #464 (TCC prompts), superseded PR #465. +- `Makefile` (`run-app`, `install-dev-build`, `sync-cli-version`, `embed-cli-debug`, + `BUILD_SETTINGS_CACHE`), `supacode.xcodeproj/project.pbxproj` (Debug configuration), + `.gitignore` (`.build_settings_cache.json`). + +## Current state + +All four changes are live as of 2026-07-12: Debug builds produce `Prowl Debug.app` with +bundle id `com.onevcat.prowl.debug`, `run-app` launches the Debug executable directly +regardless of other running instances, and the build-settings cache is used by both +`run-app` and `install-dev-build`. See [001-action.md](001-action.md) for the verified +inventory and the cache-staleness caveat under Open questions. diff --git a/docs-ai/017-upstream-sync-process/000-plan.md b/docs-ai/017-upstream-sync-process/000-plan.md new file mode 100644 index 00000000..cbd14102 --- /dev/null +++ b/docs-ai/017-upstream-sync-process/000-plan.md @@ -0,0 +1,93 @@ +# 017 — Upstream Sync Process: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-08 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #193 (process establishment); review rounds landed as #268, #547, #549 and direct docs commits `013be91e`, `2948302e` | +| **Sources** | [upstream-ledger.md](upstream-ledger.md) (living ledger, migrated from the fork change log), [batch-2026-07-06-post-v0.10.5.md](batch-2026-07-06-post-v0.10.5.md) (kept verbatim), PR #193/#547 descriptions, `.claude/skills/check-upstream-changes/SKILL.md` | +| **Related** | [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md) (sync/release mechanics), [013-prowl-cli](../013-prowl-cli/000-plan.md), [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md), [030-agent-status-detection](../030-agent-status-detection/000-plan.md) | + +## Background + +`onevcat/Prowl` tracks `supabitapp/supacode` as the `upstream` remote. In the fork's +first month upstream was integrated wholesale: repeated +`git merge upstream/main` commits (last one on 2026-03-24 via PR #46; the merge-base +with upstream is still `db6d189b`, 2026-03-23). As fork-only surface grew — the Prowl +rebrand, Canvas, the diff window, the fork's own CLI and release pipeline — wholesale +merges stopped being viable, and a transitional cherry-pick round (PR #119, 2026-04-01) +showed that selective adoption works but needs bookkeeping. + +The bookkeeping that existed was a per-commit tracking table in the fork change log +(preserved as the "Old Log" section of [upstream-ledger.md](upstream-ledger.md)): one +row per fork commit with a `Fork only` / `Merged upstream` status. That format answers +"what did the fork change" but not "which upstream commits have we reviewed, what did we +decide about them, and where do we resume next time" — every upstream check had to +re-derive its starting point. + +## Goals + +- **Bounded review scope**: a recorded *upstream baseline* commit; each review round + only inspects `baseline..upstream/main`, then advances the baseline. +- **Durable decisions**: skip/defer verdicts are written down with rationale so future + rounds do not re-litigate them (e.g. "no zmx in this fork" holds across rounds). +- **Provenance**: ported changes map upstream commit/PR → fork PR in a table, so later + archaeology (like this backfill) can trace any fork behavior to its upstream origin. +- **Cheap reconnaissance**: a read-only skill an agent can run any time to get a + categorized briefing of what is new upstream, without touching the tree. +- **Normal review flow for ports**: upstream changes enter the fork as dedicated fork + PRs (re-implementations adapted to Prowl's architecture where needed), not as opaque + merge commits. + +**Non-goals** + +- Resuming wholesale `upstream/main` merges (the mechanical sync script from + [001](../001-fork-bootstrap-and-release-pipeline/000-plan.md) remains for that model, + but the fork moved off it). +- Maintaining the old per-commit status table; it is frozen as the ledger's "Old Log". + +## Design / Approach + +Three pieces, established together in PR #193 (2026-04-08): + +1. **The ledger** — the fork change log converted from a per-commit table to a dated + review log, newest first. It opens with an **Upstream Baseline** table (commit, tag, + date) meaning "everything up to and including this commit has been reviewed". Each + dated entry records one review round with a consistent decision taxonomy that + stabilized over the rounds: *Ported into the fork* (upstream ref → fork PR mapping), + *Already present in the fork*, *Reviewed and skipped (decision recorded)*, and + *Deferred with tracking*. The ledger lives on as + [upstream-ledger.md](upstream-ledger.md) in this folder. +2. **The `/check-upstream-changes` skill** + (`.claude/skills/check-upstream-changes/SKILL.md`) — read-only reconnaissance: read + the baseline from the ledger, `git fetch upstream main`, list + `baseline..upstream/main`, summarize each commit with its PR number, and categorize + into **Needs Attention** (possible conflict with fork customizations) vs **Safe to + Merge**. It explicitly must not modify files or run a sync. +3. **The review-round workflow** — run the skill, investigate the new commits, decide + port/already-present/skip/defer per commit or per track, land ports as fork PRs, + then append a dated ledger entry and advance the baseline. + +## Alternatives & decisions + +- **Per-commit table vs dated log** (#193): the table was retired because it tracked + fork commits, not upstream review state; the dated log tracks decisions and a resume + point. The old table is preserved read-only ("Old Log") because the skill still uses + it as context for spotting overlap with fork customizations. +- **Wholesale merge vs selective port**: after 2026-03-24 no upstream merge commits + exist; ledger entries state ports are "dedicated fork PRs (fork implementations may + differ to fit Prowl's architecture)". Track-level skips (Tuist, zmx, upstream hooks, + upstream CLI) would be impossible under wholesale merging. +- **Skips are decisions, not omissions**: starting with the 2026-04-20 round, each skip + records why (e.g. Tuist migration skipped because the fork's SwiftPM `ProwlCLI` layout + sidesteps the archive bug that motivated it upstream). +- **Plan-doc-first for large batches** (2026-07 round): per-commit verdicts are written + into a standalone batch plan before any port PR is opened, and long-tail items are + deferred into Linear issues — see [002-plan-doc-batch-workflow.md](002-plan-doc-batch-workflow.md). + +## Amendments + +- Updated 2026-07-09: the post-v0.10.5 round introduced the plan-doc-first batch + workflow with Linear-tracked deferrals — see + [002-plan-doc-batch-workflow.md](002-plan-doc-batch-workflow.md) diff --git a/docs-ai/017-upstream-sync-process/001-action.md b/docs-ai/017-upstream-sync-process/001-action.md new file mode 100644 index 00000000..29e4b518 --- /dev/null +++ b/docs-ai/017-upstream-sync-process/001-action.md @@ -0,0 +1,53 @@ +# 017 — Upstream Sync Process: Action Log + +Per-commit verdicts for every round live in [upstream-ledger.md](upstream-ledger.md); +this log records the rounds themselves and their headline decisions. + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-03-06 – 03-24 | (pre-process) wholesale `upstream/main` merges; last one lands via PR #46; merge-base frozen at upstream `db6d189b` | PR #46 | +| 2026-04-01 | (transitional) selective upstream cherry-picks + dependency bumps | PR #119 | +| 2026-04-08 | **Process established**: change log converted to dated review log with Upstream Baseline table; `/check-upstream-changes` added as a skill; baseline set to `0150ceaf` (v0.8.0). Review noted upstream `ce214902` overlaps fork issue #178 (global worktree defaults) | PR #193 | +| 2026-04-20 | **Round 2** — 47 commits through `c4e9be3b` (v0.8.1). Headline skips: upstream **Tuist migration** (fork's SwiftPM `ProwlCLI` sidesteps the archive bug that motivated it; migrating would rewrite the release/notarization flow for no gain) and upstream **`supacode` CLI** (orchestration-focused, orthogonal to the fork's agent-scripting `prowl` CLI). Upstream #225 (`toggle-background-opacity` cleanup) deferred "to next sync of the affected files" | commit `013be91e` | +| 2026-05-08 | **Round 3** — 25 commits through `5e88ec5d` (post-v0.8.5). First round with a port batch: fork PRs #255, #256, #260–#264, #266 (Ghostty key routing, fork-aware PR repo resolution, worktree history, window title/quit behavior, Android Studio, CI concurrency, …). Two further listed ports were closed unmerged: #265 (sidebar right-arrow focus) and #267 (`CFBundleIconName`). Headline skip: upstream per-repo title/color (#276 upstream) — fork keeps its richer repo-appearance model (see [025](../025-repo-identity-appearance/000-plan.md)) | PR #268 (`5dd20d81`) | +| 2026-05-09 | Ledger records the fork-only Ghostty C API patch (`ghostty_surface_pid`, patched `onevcat/ghostty` branch) — detail in [030](../030-agent-status-detection/000-plan.md) | commit `963480cd` | +| 2026-06-09 | **Round 4** — 55 commits through `1d888dbc` (post-v0.10.2, upstream v0.9.0→v0.10.2). Ports as fork PRs #414–#425 (perf wave #414–#417, gh JSON noise #418, merge-queue state #425, worktree name/parent override #424, …). Headline skips: the whole **zmx terminal-persistence track** (fork keeps its own layout persistence, see [014](../014-terminal-layout-persistence/000-plan.md)) and **hook-driven agent integrations** (upstream settings/hook modules absent in fork). Entry adds two new ledger sections: "Not yet ported — re-evaluate next round" and a full upstream commit inventory | commit `2948302e` | +| 2026-07-06 – 07-09 | **Round 5** — 70 commits through `bcbc4059` (post-v0.10.5, upstream v0.10.3→v0.10.5). First plan-doc-first round: per-commit verdicts written up before porting (PR #547, kept verbatim as [batch-2026-07-06-post-v0.10.5.md](batch-2026-07-06-post-v0.10.5.md)); ports #541–#546; **remote SSH track deferred → Linear CLAW-98**, **searchable base-ref filter deferred → Linear CLAW-99**; baseline advanced after the port PRs merged | PRs #547, #541–#546, #549 (`2197d13e`) — see [002](002-plan-doc-batch-workflow.md) | +| 2026-07-12 | `/check-upstream-changes` retargeted to read the ledger at `docs-ai/017-upstream-sync-process/upstream-ledger.md` as part of the docs-ai migration | branch `docs/ai-docs-backfill` | + +## Outcome & current state (as of 2026-07-12) + +- Current baseline: `bcbc4059` (post-v0.10.5, 2026-07-06), recorded in the ledger's + Upstream Baseline table. +- The ledger is [upstream-ledger.md](upstream-ledger.md) in this folder (living, + non-numbered; migrating from the fork's old change-log location in the docs-ai + migration). Its "Old Log" tail preserves the retired per-commit table. +- `.claude/skills/check-upstream-changes/SKILL.md` exists and already reads the + baseline from the `docs-ai/017-upstream-sync-process/upstream-ledger.md` path. +- The `upstream` remote points at `supabitapp/supacode`; no upstream merge commit + exists after `db6d189b` (2026-03-23) — all later upstream adoption is via fork PRs + listed in the ledger's port tables. +- Five review rounds are on record: 2026-04-08 (v0.8.0), 2026-04-20 (v0.8.1), + 2026-05-08 (post-v0.8.5), 2026-06-09 (post-v0.10.2), 2026-07-09 (post-v0.10.5). + +## Deviations from plan + +- Baseline-advance timing was only formalized in round 5 ("advance the baseline only + after the batch's port PRs merge"); earlier rounds advanced the baseline in the same + commit as the review log, before or alongside the ports. +- The ledger's 2026-05-08 "Ported to Prowl PRs" list includes #265 and #267, but both + were closed without merging (#267 deliberately: "not useful since we are not using + Icon Composer yet"); the ledger entry was never corrected. + +## Open questions + +- The 2026-04-20 deferred item — replace the fork's `toggle-background-opacity` + implementation with upstream #225's runtime-level version "when next editing the + affected files" — appears never executed: the toggle state is still per-view + (`isBackgroundOpaqueOverride` in + `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`), although those files + have been edited since. +- PR #265 (sidebar right-arrow focus port) was closed unmerged after an LGTM review, + with no recorded reason; unclear whether the behavior was superseded or dropped. diff --git a/docs-ai/017-upstream-sync-process/002-plan-doc-batch-workflow.md b/docs-ai/017-upstream-sync-process/002-plan-doc-batch-workflow.md new file mode 100644 index 00000000..f4a95a59 --- /dev/null +++ b/docs-ai/017-upstream-sync-process/002-plan-doc-batch-workflow.md @@ -0,0 +1,45 @@ +# 017 — Amendment: Plan-Doc-First Batch Workflow (2026-07 round) + +## Context + +The first four review rounds wrote their conclusions directly into the ledger after the +fact. The post-v0.10.5 round (70 upstream commits, v0.10.3→v0.10.5) was large enough +that decisions needed to be recorded — and reviewable — *before* any port PR was +opened, and two upstream tracks were too big to either port or silently skip. + +## Change + +The round introduced a three-artifact workflow on top of the existing process: + +1. **Batch plan doc first** (PR #547, merged 2026-07-08): a standalone investigation + record with per-commit verdicts in four buckets — already present in fork, port, + skip (decision recorded), defer with tracking — plus a PR checklist. Kept verbatim + in this folder as [batch-2026-07-06-post-v0.10.5.md](batch-2026-07-06-post-v0.10.5.md); + the ledger entry for the round points at it instead of restating rationale. +2. **Port PRs referencing the plan**: each port theme landed as its own fork PR — + #541 (gh detection & login-shell hardening), #542 (Zed Preview / IDEA EAP / Nova), + #543 (`TERM_PROGRAM=prowl`), #544 (symlink-preserving JSON config writes), + #545 (notification sound picker, fork default kept as the classic chime), #546 + (mute notifications for the viewed surface, stacked on #545). All merged 2026-07-08. +3. **Linear-tracked deferrals**: work too large for the round is moved out of the + ledger's "re-evaluate next round" limbo into tracked issues — the remote SSH track + (≈ +12k/−3.7k lines) → **CLAW-98**, the searchable base-ref filter → **CLAW-99** — + each with the fork's preferred adoption approach captured in the issue. + +The round also fixed the baseline-advance ordering: the baseline moves to the new tip +(`bcbc4059`) only after the batch's port PRs merge (PR #549, commit `2197d13e`, +2026-07-09). + +## Refs + +- PR #547 (batch plan), PRs #541–#546 (ports), PR #549 (baseline advance) +- [batch-2026-07-06-post-v0.10.5.md](batch-2026-07-06-post-v0.10.5.md) (verbatim record) +- [upstream-ledger.md](upstream-ledger.md) — entry "2026-07-09 — Review through post-v0.10.5" +- Linear CLAW-98, CLAW-99 (CLAW team, project Prowl) + +## Current state + +Baseline is `bcbc4059` (post-v0.10.5). CLAW-98/CLAW-99 remain open deferrals. The next +`/check-upstream-changes` run diffs against `bcbc4059` only. Whether future rounds +always start with a batch plan doc is a per-round judgment; the pattern is available +and this round is its precedent. diff --git a/docs-ai/018-archived-worktrees/000-plan.md b/docs-ai/018-archived-worktrees/000-plan.md new file mode 100644 index 00000000..3b1f1138 --- /dev/null +++ b/docs-ai/018-archived-worktrees/000-plan.md @@ -0,0 +1,92 @@ +# 018 — Archived Worktrees: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-09 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #187, #191 (amendment: #512) | +| **Sources** | Fork issues #181 and #174, PR #187/#191/#512 descriptions, change-list 2026-04-08 review batch (see [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md)) | +| **Related** | [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md), [012-keybinding-system](../012-keybinding-system/000-plan.md), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +Worktree archiving (hide a worktree from the sidebar without deleting it, with an +Archived Worktrees panel reachable from Menu Bar > Worktrees) is inherited from upstream +Supacode. By early April 2026 two pains had accumulated in daily use: + +1. **Discoverability** (issue #181): the confirmation alert shown when archiving never + said where archived worktrees go. Users archived a worktree and then could not find + it again; the only entry points were a menu item and a sidebar footer button. +2. **Unbounded growth** (issue #174): archived worktrees were tracked as a flat + `[Worktree.ID]` list with no timestamps and no cleanup. Power users ended up with a + long archived list requiring manual deletion. + +The 2026-04-08 upstream review batch had just surfaced that upstream shipped its own +auto-delete for archived worktrees (upstream #214, `666d440d`). Fork issue #174 was +filed the same day; the fork implemented its own version on top of its already-diverged +archived-worktree persistence rather than porting the upstream commit (which is not in +the fork's ancestry). + +## Goals + +- Tell the user, at the moment of archiving, where archived worktrees can be found — + including the actual keyboard shortcut, not a hardcoded one. +- Add a "View Archived Worktrees" command palette entry. +- Auto-delete archived worktrees after a configurable retention period (1/3/7/14/30 + days, or never), which requires recording *when* each worktree was archived. +- Migrate the legacy flat ID list to the timestamped model without losing data. + +### Non-goals + +- Changing archive semantics themselves (archive scripts, archive-on-merge behavior, + what can be archived) — that evolution belongs to + [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md). + +## Design / Approach + +**Discoverability (#187).** Extend the single and bulk archive confirmation alerts with +"Find … later in Menu Bar > Worktrees > Archived Worktrees (⌃⌘A)", where the shortcut +string is resolved at runtime from `AppShortcuts.archivedWorktrees.display` so the copy +tracks rebinding (012-keybinding-system). Add a `viewArchivedWorktrees` command palette +item (archivebox icon) that dispatches the existing +`RepositoriesFeature.Action.selectArchivedWorktrees` and maps to +`AppShortcuts.CommandID.archivedWorktrees` so the palette row shows the shortcut hint. + +**Auto-delete with retention (#191).** Replace the flat `[Worktree.ID]` archived list +with a domain struct `ArchivedWorktree { id, archivedAt }`. Add an `AutoDeletePeriod` +enum whose raw value is the number of days (1/3/7/14/30, plus a DEBUG-only +`immediately = 0` for testing); `nil` means never. The setting lives in +`GlobalSettings.archivedAutoDeletePeriod` (settings file) and is wired +`GlobalSettings` → `SettingsFeature` → `RepositoriesFeature`. The sweep +(`autoDeleteExpiredArchivedWorktrees`) runs on repository load and whenever the setting +changes; expired entries are routed through the existing confirmed-delete lifecycle path +(`worktreeLifecycle(.deleteWorktreeConfirmed)`), skipping main worktrees and worktrees +already being deleted, with branch deletion following the global +`deleteBranchOnDeleteWorktree` flag and only for Prowl-created worktrees. Persistence +moves to a new `archivedWorktrees` app-storage key with a one-time migration from the +legacy `archivedWorktreeIDs` key (migrated entries are stamped with the migration date). + +## Alternatives & decisions + +- **Fork-native implementation over porting upstream's.** Upstream's auto-delete + (`666d440d`, upstream #214) predates #191 by a week and was reviewed in the + 2026-04-08 batch, but the fork's archived persistence had already diverged; #191 was + implemented on a fork branch and the upstream commit was never merged. +- **`[ArchivedWorktree]` array instead of the `[Worktree.ID: Date]` dictionary** + sketched in issue #174 — an `Identifiable` struct list codecs cleanly through + `@Shared(.appStorage)` and reads better in tests. +- **Reuse the confirmed-delete path** for expired entries instead of a separate bulk + deletion routine, so auto-delete inherits all lifecycle safeguards for free. +- **DEBUG-only "Immediately" period** so retention can be exercised without waiting a + day. +- Issue #174's design notes sketched a confirmation alert when shortening the retention + window would immediately delete existing archived worktrees; this was **not + implemented** — changing the setting triggers the sweep directly (see 001-action.md + deviations). + +## Amendments + +- Updated 2026-06-26: the sidebar archived-worktrees button became a toggle with an + explicit exit affordance (#512) — see + [002-archived-button-toggle.md](002-archived-button-toggle.md) diff --git a/docs-ai/018-archived-worktrees/001-action.md b/docs-ai/018-archived-worktrees/001-action.md new file mode 100644 index 00000000..e88a5b3a --- /dev/null +++ b/docs-ai/018-archived-worktrees/001-action.md @@ -0,0 +1,72 @@ +# 018 — Archived Worktrees: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-09 | Discoverability: archive alerts name the menu location + live shortcut; "View Archived Worktrees" palette command | PR #187 (closes #181) | +| 2026-04-09 | Auto-delete with retention: `ArchivedWorktree` model, `AutoDeletePeriod` setting + Worktree settings picker, sweep on load/setting change, legacy persistence migration | PR #191 (closes #174) | +| 2026-06-26 | Archived-worktrees button becomes a toggle with exit affordance (community PR, Alex-ai-future) | PR #512 — see [002-archived-button-toggle.md](002-archived-button-toggle.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Domain/ArchivedWorktree.swift` — `ArchivedWorktree { id: Worktree.ID, + archivedAt: Date }`, `Codable`/`Identifiable`. +- `supacode/Features/Settings/Models/AutoDeletePeriod.swift` — raw-value-in-days enum + (`oneDay = 1` … `thirtyDays = 30`), `Comparable`, with DEBUG-only + `immediately = 0` ("Immediately (debug)"). +- `supacode/Features/Settings/Models/GlobalSettings.swift` — optional + `archivedAutoDeletePeriod`, encoded as the raw day count; `nil` = never (default). + Picker ("Never" + all periods) in + `supacode/Features/Settings/Views/WorktreeSettingsView.swift`, under the Cleanup + grouping. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` — + `setArchivedAutoDeletePeriod` stores the period and immediately sends + `autoDeleteExpiredArchivedWorktrees`; the same action is also appended to the + repositories-loaded effects when a period is set. The sweep computes + `cutoff = now - days` and dispatches + `worktreeLifecycle(.deleteWorktreeConfirmed(...))` per expired entry, skipping main + worktrees and IDs in `deletingWorktreeIDs`; `deleteBranch` is true only when + `deleteBranchOnDeleteWorktree` is on and the worktree is in + `prowlCreatedWorktreeIDs`. +- `supacode/Clients/Repositories/RepositoryPersistenceClient.swift` — loads/saves the + `archivedWorktrees` app-storage key; on first load with an empty new key it migrates + legacy `archivedWorktreeIDs` (stamping `archivedAt` with the migration date) and + clears the legacy key. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift` — + `archiveWorktreeAlertMessage(for:)` / `archiveWorktreesAlertMessage()` build the + "Find … later in Menu Bar > Worktrees > Archived Worktrees (…)" copy from + `AppShortcuts.archivedWorktrees.display`. +- `supacode/Features/CommandPalette/CommandPaletteItem.swift` — `.viewArchivedWorktrees` + maps to `AppShortcuts.CommandID.archivedWorktrees` (action id `archived_worktrees`, + default ⌘⌃A in `supacode/App/AppShortcuts.swift`). +- Archived view UI: `supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift` + (grouped by repo, Unarchive + Delete Selected) and `ArchivedWorktreeRowView.swift`. +- Toggle behavior from #512: `preArchivedWorktreeID` in `RepositoriesFeature.State`, + toggle logic in `selectArchivedWorktrees`, icon/label switching in + `supacode/Features/Repositories/Views/SidebarFooterView.swift` and + `supacode/Commands/WorktreeCommands.swift` — details in + [002-archived-button-toggle.md](002-archived-button-toggle.md). + +User-facing behavior is documented in +`docs/components/repositories-and-worktrees.md` ("Archiving a worktree"), +`docs/reference/keyboard-shortcuts.md`, and `docs/reference/settings-fields.md`. + +## Deviations from plan + +- Issue #174 sketched a `[Worktree.ID: Date]` dictionary; the implementation used a + `[ArchivedWorktree]` struct array (decided during #191, reflected in its PR body). +- Issue #174's confirmation alert for retention-window shortening (warn when a shorter + period would immediately delete existing archived worktrees) was not implemented; + changing the setting runs the sweep at once. The DEBUG-only "Immediately" option + makes this observable in debug builds. + +## Open questions + +- Upstream later added "re-surface archived worktrees in the sidebar while their delete + script runs" (upstream #346, `b69ce38e`), reviewed 2026-06-09 and left in the + "not yet ported — re-evaluate" bucket; the fork still lacks that affordance during + slow archive-delete scripts. +- Shortening the retention period silently deletes newly-expired archived worktrees + without confirmation (see deviations). Deliberate simplification as far as the + sources show, but worth revisiting if auto-delete complaints appear. diff --git a/docs-ai/018-archived-worktrees/002-archived-button-toggle.md b/docs-ai/018-archived-worktrees/002-archived-button-toggle.md new file mode 100644 index 00000000..9a24c9b4 --- /dev/null +++ b/docs-ai/018-archived-worktrees/002-archived-button-toggle.md @@ -0,0 +1,39 @@ +# 018 — Amendment: Archived Button Toggle (#512) + +## Context + +Entering the Archived Worktrees view had no obvious way out: the sidebar footer button +only navigated *into* the view, so users had to click a different worktree or repository +to leave, with no visual feedback that they were "inside" the archived view. Canvas and +Shelf already behaved as toggles; the archived button was the odd one out. Community +contribution by Alex-ai-future, merged with one review-feedback round. + +## Change + +- `RepositoriesFeature.State` gained `preArchivedWorktreeID` to remember the selection + before entering the archived view. +- `selectArchivedWorktrees` became a toggle: when already showing archived worktrees it + restores the previous worktree (re-opening it and queuing terminal focus via + `pendingTerminalFocusWorktreeIDs`), or clears the selection if the remembered worktree + is no longer valid; otherwise it records the current selection and enters the archived + view. +- `SidebarFooterView` swaps the button icon `archivebox` → `arrow.uturn.left` while in + the archived view; the menu item and tooltip switch between "Archived Worktrees" and + "Exit Archived Worktrees" (`supacode/Commands/WorktreeCommands.swift`). +- Test `selectArchivedWorktreesTogglesBackToPreviousWorktree` added + (`supacodeTests/ShelfFeatureTests.swift`); existing archived-selection tests updated + for the new state field. + +## Refs + +- PR #512 (merged 2026-06-26), commits `09c8d33b` (implementation), `321d5762` + (review feedback), merge `f5e6ad81`. + +## Current state + +Verified in the tree as of 2026-07-12: `preArchivedWorktreeID` in +`supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`; toggle logic in +`RepositoriesFeature+CoreReducer.swift` (`case .selectArchivedWorktrees`); dynamic +icon/label in `supacode/Features/Repositories/Views/SidebarFooterView.swift` and +`supacode/Commands/WorktreeCommands.swift`. The toggle behavior (⌘⌃A to enter, press +again to return) is documented in `docs/components/repositories-and-worktrees.md`. diff --git a/docs-ai/019-worktree-creation-and-lifecycle/000-plan.md b/docs-ai/019-worktree-creation-and-lifecycle/000-plan.md new file mode 100644 index 00000000..210c2f13 --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/000-plan.md @@ -0,0 +1,88 @@ +# 019 — Worktree Creation & Lifecycle: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-12 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #167, #189, #190, #192 (initial wave); later waves #260/#419, #375/#383, #424/#427, #520 | +| **Sources** | PR descriptions #167/#189/#190/#192/#260/#375/#383/#419/#424/#427/#520; fork issues #166/#175/#176/#178; `docs-ai/017-upstream-sync-process/upstream-ledger.md` (2026-04-08 and 2026-05-08 review batches) | +| **Related** | [018-archived-worktrees](../018-archived-worktrees/000-plan.md), [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md), [034-worktree-watcher-correctness](../034-worktree-watcher-correctness/000-plan.md), [044-foundation-model-branch-names](../044-foundation-model-branch-names/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +Worktrees are Prowl's unit of parallel agent work: each agent session normally lives in +its own git worktree created from the New Worktree dialog. By April 2026 the inherited +creation and merge handling had several rough edges: + +- The base-ref picker dropped local branches that track a remote (`branchRefs(for:)` + mapped each local branch to its upstream ref), so the picker looked remote-only + (fork issue #166). +- Creation used whatever the local remote-tracking refs happened to be — starting a + worktree from a stale `origin/main` was easy (fork issue #176). +- When a worktree's PR merged, the only automation was a boolean "automatically archive" + toggle; there was no "delete" option (fork issue #175). +- `copyIgnoredOnWorktreeCreate`, `copyUntrackedOnWorktreeCreate`, and + `pullRequestMergeStrategy` existed only as per-repo settings, so every repository had + to be configured individually (fork issue #178). + +This entry covers that April wave plus the later lifecycle waves that accreted on the +same flows: worktree history navigation, deletion safety, per-creation placement +overrides, and the redesigned repository intake ("Add to Prowl") popover. + +## Goals + +- Base-ref options include local branches alongside upstream refs (dedup/sort kept). +- Optional (default-on) `git fetch <remote>` before worktree creation, resolved against + the base ref; fetch failure logs and continues — it must never block creation. +- Replace the auto-archive boolean with a `MergedWorktreeAction?` picker: do nothing / + archive / delete, with legacy settings migration. +- Promote copy flags and PR merge strategy to global defaults with per-repo optional + overrides (`nil` = use global), shown as "Global (current value)" pickers per repo. + +**Non-goals** (initially): per-creation placement control, branch-name suggestion, +deletion-safety rework, clone-from-URL intake — all later waves (see Amendments). + +## Design / Approach + +- **Base refs** (#167): `GitClient.branchRefs(for:)` returns both the local branch ref + and its upstream ref instead of collapsing tracking branches into their upstream. +- **Fetch before creation** (#189): global `fetchOriginBeforeWorktreeCreation` (default + `true`) plus a per-creation toggle in the prompt. After resolving the base ref, match + it against `git remote` output (longest prefix) and run `git fetch <remote>`; a + dedicated fetch stage in `WorktreeCreationProgress` surfaces it in the progress UI. +- **Merged worktree action** (#190): `MergedWorktreeAction` enum (`archive`/`delete`), + optional in `GlobalSettings`; legacy `automaticallyArchiveMergedWorktrees: true` + decodes to `.archive`, `false` to `nil`. `.delete` dispatches the same + `deleteWorktreeConfirmed` path as manual deletion and honors the "Delete local branch + with worktree" setting. +- **Global defaults** (#192): the three settings move into `GlobalSettings` with UI in + the Worktree and GitHub settings tabs; `RepositorySettings` keeps optional overrides + and creation/merge logic falls back to the global value when the repo value is `nil`. + +## Alternatives & decisions + +- **Fork-first despite known upstream overlap**: the 2026-04-08 upstream review (see the + ledger) had already spotted upstream's own merged-action picker (`4db25220`) and global + defaults (`ce214902`) in v0.8.0, while the fork branches for #190/#192 were in flight. + The recorded decision: merge the fork implementation now, and on the next upstream sync + "prefer upstream's implementation where equivalent; keep fork extensions if any". +- **Fetch is best-effort**: errors append to progress output but never abort creation, + trading strict freshness for a creation flow that works offline. +- **Delete action reuses the manual path**: automated post-merge deletion goes through + `deleteWorktreeConfirmed` rather than a separate code path, so safety changes to manual + deletion (amendment 003) automatically apply to it. + +## Amendments + +- Updated 2026-05-09/2026-06-08: browser-style worktree history navigation (#260, upstream + port) + terminal focus after keyboard navigation (#419) — see + [002-worktree-history-navigation.md](002-worktree-history-navigation.md) +- Updated 2026-05-30/2026-06-03: explicit + safe branch deletion (#375) and failed-cleanup + hardening (#383) — see + [003-safe-branch-deletion-and-cleanup.md](003-safe-branch-deletion-and-cleanup.md) +- Updated 2026-06-08/2026-06-09: Advanced placement overrides in the New Worktree dialog + (#424, upstream port) + field labels (#427) — see + [004-advanced-placement-overrides.md](004-advanced-placement-overrides.md) +- Updated 2026-06-28: Add to Prowl popover redesign with clone support (#520) — see + [005-add-to-prowl-clone.md](005-add-to-prowl-clone.md) diff --git a/docs-ai/019-worktree-creation-and-lifecycle/001-action.md b/docs-ai/019-worktree-creation-and-lifecycle/001-action.md new file mode 100644 index 00000000..12b58673 --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/001-action.md @@ -0,0 +1,85 @@ +# 019 — Worktree Creation & Lifecycle: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-07 | Base-ref options include local branches alongside upstream refs (fork issue #166) | PR #167 | +| 2026-04-12 | Optional git fetch before worktree creation: `fetchOriginBeforeWorktreeCreation` global setting (default on), per-creation toggle in the prompt, longest-prefix remote matching, non-blocking failures; fetch progress stage (renamed `fetchingOrigin` → `fetchingRemote` on the same branch, `5f866b3f`) (fork issue #176) | PR #189 | +| 2026-04-14 | Merged worktree action picker: `MergedWorktreeAction?` (do nothing / archive / delete) replaces the auto-archive boolean, with legacy migration (fork issue #175) | PR #190 | +| 2026-04-14 | Copy flags + PR merge strategy promoted to global defaults with per-repo optional overrides shown as "Global (current value)" pickers (fork issue #178; upstream overlap `ce214902` noted in the 2026-04-08 review) | PR #192 | +| 2026-05-09 | Browser-style worktree history navigation (`⌘⌥[` / `⌘⌥]`), disabled while Shelf/Canvas is active (upstream port, 2026-05-08 batch) — see [002-worktree-history-navigation.md](002-worktree-history-navigation.md) | PR #260 | +| 2026-05-30 | Deletion made explicit and safe: confirmation sheet with branch-deletion toggle, Prowl-created worktree tracking, `-d` before confirmed `-D`, default-branch protection — see [003-safe-branch-deletion-and-cleanup.md](003-safe-branch-deletion-and-cleanup.md) | PR #375 | +| 2026-06-03 | Failed-cleanup hardening: exact porcelain path match, `.git`-metadata check before relocation, no branch deletion from failed-creation cleanup — see [003-safe-branch-deletion-and-cleanup.md](003-safe-branch-deletion-and-cleanup.md) | PR #383 | +| 2026-06-08 | Terminal focused after next/previous and history worktree navigation (port of upstream #371, reimplemented on `pendingTerminalFocusWorktreeIDs`) — see [002-worktree-history-navigation.md](002-worktree-history-navigation.md) | PR #419 | +| 2026-06-08 | Advanced section in the New Worktree dialog: per-creation worktree name + parent folder overrides, live destination preview, applied via `wt sw --path` (port of upstream #351, GUI only) — see [004-advanced-placement-overrides.md](004-advanced-placement-overrides.md) | PR #424 | +| 2026-06-09 | Visible labels + caption for the Advanced fields (TextField labels are not rendered under `.roundedBorder`) | PR #427 | +| 2026-06-27 | On-device Foundation Model branch-name suggestion added to the same dialog | PR #518 (owned by [044](../044-foundation-model-branch-names/000-plan.md)) | +| 2026-06-28 | Add to Prowl popover redesign: drop zone, Browse, Clone-from-URL form with clipboard prefill, Add Workspace; auto-select after add — see [005-add-to-prowl-clone.md](005-add-to-prowl-clone.md) | PR #520 | + +## Outcome & current state (as of 2026-07-12) + +- **Git plumbing** — `supacode/Clients/Git/GitClient.swift`: `branchRefs(for:)` returns + local + upstream refs; `deleteLocalBranch(_:_:force:)` backs both `-d` and confirmed + `-D` deletion; worktree removal requires an exact `git worktree list --porcelain` path + match and only relocates directories containing `.git` metadata + (`relocateWorktreeDirectory`); the create path appends `--path` for placement + overrides. `GitRemoteMatcher` (in `supacode/Clients/Git/GitClientTypes.swift`) does the + longest-prefix base-ref → remote match. +- **Creation flow** — + `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift`: + threads `fetchRemote` (prompt value, falling back to + `settingsFile.global.fetchOriginBeforeWorktreeCreation`), sets the `.fetchingRemote` + stage of `WorktreeCreationStage` (`supacode/Domain/WorktreeCreationProgress.swift`), + and registers created worktrees in the `@Shared(.appStorage)` + `prowlCreatedWorktreeIDs` list. +- **Prompt** — `supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift` + + `supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift`: fetch + toggle, default-collapsed Advanced `DisclosureGroup` with labeled name/parent-folder + fields, live path preview, and the branch-name auto-suggestion row + ([044](../044-foundation-model-branch-names/000-plan.md)). +- **Placement** — `supacode/Features/Repositories/Models/WorktreePlacementOverride.swift` + (leaf-name validation) and `supacode/Support/SupacodePaths.swift` + (`resolvedWorktreeDirectory` / `previewWorktreeDirectory`). +- **Settings** — `supacode/Features/Settings/Models/GlobalSettings.swift` holds + `mergedWorktreeAction: MergedWorktreeAction?` (default `nil`), + `fetchOriginBeforeWorktreeCreation` (default `true`), `copyIgnoredOnWorktreeCreate` / + `copyUntrackedOnWorktreeCreate` (default `false`), `pullRequestMergeStrategy` + (default `.merge`); `supacode/Features/Settings/Models/MergedWorktreeAction.swift`; + per-repo optionals in `supacode/Features/Settings/Models/RepositorySettings.swift`. + UI: `supacode/Features/Settings/Views/WorktreeSettingsView.swift` (merged-action + picker, copy-flag toggles, branch-delete toggle), + `GithubSettingsView.swift` (merge strategy), `RepositorySettingsView.swift` + ("Global (…)" override pickers). +- **Merged-PR automation** — + `supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift` + switches on `state.mergedWorktreeAction`; `.delete` passes + `deleteBranch: deleteBranchOnDeleteWorktree && prowlCreatedWorktreeIDs.contains(id)`. +- **Deletion** — + `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift` + (delete + `ForceDeleteBranchRequest` flow) and + `supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift`. +- **History navigation** — stacks and `navigateWorktreeHistory` in + `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` / + `RepositoriesFeature+Selection.swift` (50-entry cap); menu commands in + `supacode/Commands/WorktreeCommands.swift`. +- **Intake** — `supacode/Features/Repositories/Views/AddToProwlView.swift` and + `CloneRepositoryView.swift`. +- **User docs** — `docs/components/repositories-and-worktrees.md` covers the fetch + toggle, Advanced section, history shortcuts, and the Add popover. + +## Deviations from plan + +- PR #189's description names the progress stage `fetchingOrigin`; it was renamed to + `.fetchingRemote` on the same branch before merge (`5f866b3f`). The settings key kept + the original `fetchOriginBeforeWorktreeCreation` name while the UI says "Fetch remote". +- The 2026-04-08 decision to "prefer upstream's implementation where equivalent" for + global defaults did not visibly replace the fork's code: the current tree matches the + #190/#192 fork model (fork enum `MergedWorktreeAction`, fork settings shape). + +## Open questions + +- Whether the v0.8.1-era upstream sync actually reconciled `ce214902`/`4db25220` against + the fork's #190/#192 (as the 2026-04-08 decision instructed) is not recorded in the + ledger; the surviving implementation is the fork's, so the overlap appears to have been + resolved in the fork's favor without an explicit note. diff --git a/docs-ai/019-worktree-creation-and-lifecycle/002-worktree-history-navigation.md b/docs-ai/019-worktree-creation-and-lifecycle/002-worktree-history-navigation.md new file mode 100644 index 00000000..181df2cb --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/002-worktree-history-navigation.md @@ -0,0 +1,47 @@ +# 019 — Amendment: Worktree History Navigation (#260, #419) + +## Context + +With many worktrees across repositories, jumping between two or three active sessions by +sidebar clicking is slow, and "go back to where I just was" had no keyboard answer. +Upstream added worktree selection history in its post-v0.8.1 window; the 2026-05-08 +upstream review batch (see `docs-ai/017-upstream-sync-process/upstream-ledger.md`) lists +"worktree history (#260)" among the ports to Prowl. + +## Change + +PR #260 (merged 2026-05-09, "Add worktree history navigation"): + +- Browser-style back/forward stacks over worktree selection in the standard + sidebar/detail navigation mode. +- History is intentionally **disabled while Shelf or Canvas is active** — those views are + the higher-level session navigation surfaces. +- Worktrees-menu commands with configurable shortcuts; defaults `⌘⌥[` / `⌘⌥]`, chosen to + avoid the existing `⌘[` / `⌘]`, `⌘⇧[` / `⌘⇧]`, and Shelf shortcuts. + +PR #419 (merged 2026-06-08, "Focus the terminal after next/previous and history worktree +navigation"): sidebar clicks and arrow selection already requested terminal focus, but +Select Next/Previous Worktree and history navigation landed on a worktree without +focusing its terminal, so keystrokes went nowhere. Ported from upstream #371 but +reimplemented on the fork's `pendingTerminalFocusWorktreeIDs` mechanism (the fork does +not use upstream's row-action focus model): next/previous now selects with +`focusTerminal: true`, and history navigation inserts the destination into +`pendingTerminalFocusWorktreeIDs`. Plain `selectWorktree` still does not steal focus. + +## Refs + +- PRs #260, #419; upstream #371 (focus fix source). +- #419 updated the exhaustive navigation tests (wrap-around, collapsed-repo skipping, + no-selection, stale-history) in `supacodeTests/RepositoriesFeatureTests`. + +## Current state + +`worktreeHistoryBackStack` / `worktreeHistoryForwardStack` and the +`worktreeHistoryBack`/`worktreeHistoryForward` actions live in +`supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`; +`navigateWorktreeHistory` plus stack pruning (50-entry `worktreeHistoryStackLimit`, +stale-ID tail pruning) in +`supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift`. Menu +commands with shortcut hints are in `supacode/Commands/WorktreeCommands.swift`, shortcut +IDs in `supacode/App/AppShortcuts.swift`. Behavior documented in +`docs/components/repositories-and-worktrees.md`. diff --git a/docs-ai/019-worktree-creation-and-lifecycle/003-safe-branch-deletion-and-cleanup.md b/docs-ai/019-worktree-creation-and-lifecycle/003-safe-branch-deletion-and-cleanup.md new file mode 100644 index 00000000..63d76adc --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/003-safe-branch-deletion-and-cleanup.md @@ -0,0 +1,50 @@ +# 019 — Amendment: Safe Branch Deletion & Cleanup Hardening (#375, #383) + +## Context + +Worktree deletion originally preselected "also delete the local branch" in a destructive +alert, for every worktree — including branches the user created outside Prowl and +branches with unmerged work. Separately, the cleanup that runs when worktree *creation* +fails removed/relocated directories based on loose matching, which risked touching the +wrong directory (and requested branch deletion for a branch the user may want to keep). + +## Change + +PR #375 (merged 2026-05-30, "Make worktree branch deletion explicit and safe"): + +- The destructive delete alert became a **confirmation sheet** with an explicit + "delete local branch" toggle; default deletion no longer preselects branch deletion. +- Prowl now tracks which worktrees it created itself + (`@Shared(.appStorage("prowlCreatedWorktreeIDs"))`); the toggle is preselected only for + Prowl-created worktrees when the `deleteBranchOnDeleteWorktree` setting is on. +- Branch deletion runs `git branch -d` first, protects main/default branches, and only + offers force deletion (`git branch -D`) behind a second confirmation alert + (`ForceDeleteBranchRequest`) when `-d` fails. +- The merged-PR `.delete` automation (#190) inherits all of this because it dispatches + the same `deleteWorktreeConfirmed` path, additionally gated on the worktree being + Prowl-created. + +PR #383 (merged 2026-06-03, "Harden failed worktree cleanup"): + +- Require an **exact `git worktree list --porcelain` path match** before removing or + relocating a worktree directory (porcelain reports raw on-disk paths, e.g. + `/private/tmp/...`, so comparison handles that). +- Only relocate existing worktree directories that actually contain `.git` metadata. +- Failed-creation cleanup no longer requests branch deletion at all. + +## Refs + +- PRs #375, #383; tests in `supacodeTests/GitClientRemoveWorktreeTests` and + `RepositoriesFeatureTests`. + +## Current state + +Deletion flow and `ForceDeleteBranchRequest` handling in +`supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift`; +sheet UI in `supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift`; +`deleteLocalBranch` plus the porcelain-match/`.git`-metadata guards +(`relocateWorktreeDirectory`) in `supacode/Clients/Git/GitClient.swift`. +`prowlCreatedWorktreeIDs` is registered on creation in +`RepositoriesFeature+WorktreeCreation.swift` and consulted in +`RepositoriesFeature+CoreReducer.swift` / `+GithubIntegration.swift` / +`+WorktreeLifecycle.swift`. diff --git a/docs-ai/019-worktree-creation-and-lifecycle/004-advanced-placement-overrides.md b/docs-ai/019-worktree-creation-and-lifecycle/004-advanced-placement-overrides.md new file mode 100644 index 00000000..c363f801 --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/004-advanced-placement-overrides.md @@ -0,0 +1,56 @@ +# 019 — Amendment: Advanced Placement Overrides (#424, #427) + +## Context + +The worktree folder always landed at `<defaultBase>/<branch>`; the only control was the +global / per-repo default base directory, which is coarse and affects every creation. +Upstream #351 added per-creation overrides; the fork ported the GUI part. + +## Change + +PR #424 (merged 2026-06-08, "Let users override the new worktree's name and parent +directory", port of upstream #351, GUI only): + +- Default-collapsed **Advanced** `DisclosureGroup` in the New Worktree dialog with two + optional fields: **Worktree name** (leaf folder, placeholder = branch name) and + **Parent folder** (placeholder = resolved base directory). Both blank keeps `wt`'s + default `base/<branch>` placement — zero behavior change. +- `WorktreePlacementOverride` model: optional `name`/`path`; `nameValidationError` + rejects slashes, `.`/`..`, and `.git`. Shared by the prompt and the reducer create + path. +- `SupacodePaths.resolvedWorktreeDirectory` (returns `nil` without an override so callers + keep the default) and `previewWorktreeDirectory` (always concrete, drives the live + destination preview / inline validation error in the dialog footer). +- Applied via `wt sw --path <dir>` with the branch kept as the positional argument, so + the sidebar name still tracks the branch. +- Not ported: upstream's `worktree-new --name/--location` CLI and deeplink params — the + fork's `prowl` CLI has no worktree-management commands and there is no deeplink + subsystem. + +PR #427 (merged 2026-06-09, "Label the worktree creation Advanced fields"): under +`.roundedBorder` style the `TextField` label argument is not rendered — only the +placeholder is, and the name field's placeholder is empty until a branch name is typed, +leaving two anonymous boxes. Fix matches the visible-label pattern of the "Branch name" +field: secondary `Text` labels above each field plus a one-line caption explaining the +blank-falls-back-to-default behavior. View-only change. + +The same dialog later gained on-device Foundation Model branch-name suggestions (#518); +that work is documented in +[044-foundation-model-branch-names](../044-foundation-model-branch-names/000-plan.md). + +## Refs + +- PRs #424, #427; upstream #351. +- Tests added with #424: `WorktreeCreationPlacementTests`, + `WorktreeCreationPromptPlacementTests`. + +## Current state + +`supacode/Features/Repositories/Models/WorktreePlacementOverride.swift`; +`resolvedWorktreeDirectory` / `previewWorktreeDirectory` in +`supacode/Support/SupacodePaths.swift`; `showAdvancedOptions` and placement threading in +`supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift` and +`RepositoriesFeature+WorktreeCreation.swift`; labeled fields in +`supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift`; the `--path` +argument is appended in `supacode/Clients/Git/GitClient.swift`. Documented in +`docs/components/repositories-and-worktrees.md`. diff --git a/docs-ai/019-worktree-creation-and-lifecycle/005-add-to-prowl-clone.md b/docs-ai/019-worktree-creation-and-lifecycle/005-add-to-prowl-clone.md new file mode 100644 index 00000000..24eca781 --- /dev/null +++ b/docs-ai/019-worktree-creation-and-lifecycle/005-add-to-prowl-clone.md @@ -0,0 +1,34 @@ +# 019 — Amendment: Add to Prowl Popover with Clone Support (#520) + +## Context + +The sidebar "Add…" button showed a system `confirmationDialog`, and Prowl had no way to +clone a remote repository from within the app — every repository had to exist on disk +first. + +## Change + +PR #520 (merged 2026-06-28, "Redesign Add to Prowl popover with clone support"): + +- Custom popover replacing the `confirmationDialog`: app-icon header, drag-and-drop zone + for folders from Finder, **Browse…** (file picker), **Clone…**, and **Add Workspace** + (workspace creation is owned by + [042-project-workspaces](../042-project-workspaces/000-plan.md)). +- **Clone…** switches the popover to a clone form (URL + location fields); the URL field + is pre-filled from the clipboard when it contains a git URL, and the result is added as + a git repository, not a plain folder. +- The newly added repository is auto-selected after add/clone/drag-drop; initial app load + does not auto-select. +- The PR's test plan notes a prior `.sheet` on `SidebarListView` caused an AttributeGraph + crash on launch — the popover-based design avoids it. + +## Refs + +- PR #520. + +## Current state + +`supacode/Features/Repositories/Views/AddToProwlView.swift` (drop zone via +`.dropDestination(for: URL.self)`, Browse/Clone/Workspace actions) and +`supacode/Features/Repositories/Views/CloneRepositoryView.swift` (clone form). The Add +popover behavior is described in `docs/components/repositories-and-worktrees.md`. diff --git a/docs-ai/020-observability/000-plan.md b/docs-ai/020-observability/000-plan.md new file mode 100644 index 00000000..0e842a2d --- /dev/null +++ b/docs-ai/020-observability/000-plan.md @@ -0,0 +1,97 @@ +# 020 — Observability: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-18 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #207, #208, #210, #211, #212, #213 | +| **Sources** | `doc-onevcat/observability.md` (migrated to [runbook.md](runbook.md) in the docs-ai migration), PR descriptions #207–#213 | +| **Related** | [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md), [032-performance-hardening](../032-performance-hardening/000-plan.md), `docs/reference/settings-fields.md` | + +## Background + +The fork inherited Sentry + PostHog SDK wiring from upstream, but the upstream +`__SENTRY_DSN__` placeholder mechanism never worked in this fork — release builds shipped +with no working credentials, so crashes and usage were invisible. The concrete motivating +incident was a user report of the *"runs for hours, memory explodes to tens of GB"* class: +without telemetry there was no way to even scope the problem. + +The response was an "observability reboot" planned as three waves (P0/P1/P2 in the PR +descriptions), landed as six PRs on a single day (2026-04-18). + +## Goals + +- **P0 — credentials** (#207): inject real Sentry DSN + PostHog key at archive time without + committing secrets; missing/unsubstituted values must make SDK init a safe no-op. +- **P1 — event quality** (#208): every PostHog event sliceable by app version / OS / device / + arch / locale; tip-channel users isolated in their own Sentry `environment`; + `session_duration_seconds` on `app_quit`. +- **Readable crashes** (#210): dSYM upload + Sentry release registration in the fork release + pipeline so stack traces symbolicate. +- **Hang signal** (#211): report main-thread stalls, but filter known system-induced hangs + (wake-from-sleep menu-bar replicant rebuilds) client-side. +- **P2 — memory watchdog** (#212): turn the memory-explosion report into queryable data — + per-session baseline plus monotonic threshold-crossing events. +- **Runbook** (#213): a single doc that lets a future human or agent start querying the right + pipeline within 30 seconds. +- Stay inside the PostHog free tier (~1M events/mo): hand-instrumented events only, all + autocapture off; Debug builds send nothing (`#if !DEBUG` gates every SDK call). + +### Non-goals + +- Sentry Profiling — samples only during transactions; useless for 8-hour memory drift. +- Watchdog Termination — iOS/tvOS/Catalyst only, unsupported on native macOS. +- `script_run` exit codes — Ghostty does not expose shell exit status via its public API. + +## Design / Approach + +- **Credentials pipeline**: `Config/Secrets.env` (gitignored, template committed) → Makefile + `-include` passes `PROWL_SENTRY_DSN` / `PROWL_POSTHOG_API_KEY` / `PROWL_POSTHOG_HOST` as + build settings on the `archive` target → substituted into `supacode/Info.plist` `$(VAR)` + placeholders → read at startup. A guard rejects empty or still-`$(`-prefixed values so a + dev build without secrets skips SDK init entirely. +- **Identity**: random install UUID persisted in `UserDefaults` + (`supacode/Support/InstallIdentifier.swift`) replaces the hardware UUID; opting out of + analytics resets both the PostHog identity and the install ID. +- **Sentry options**: `releaseName = "prowl@<CFBundleShortVersionString>"` (matches the name + the release script registers), `environment` = `"tip"` or `"production"` from the update + channel, `tracesSampleRate = 0.05`, App Hang tracking on with `appHangTimeoutInterval = 3` + and a conservative `beforeSend` filter (`SentryEventFilter`) that drops an event only when + it is an AppHang, has zero in-app frames, *and* matches a known system signature. +- **PostHog**: `enableSwizzling = false`, lifecycle/screen-view capture off; super properties + registered once from `supacode/Support/AnalyticsContext.swift`. +- **Memory watchdog**: `supacode/Support/MemoryProbe.swift` reads `phys_footprint` via + `task_info(TASK_VM_INFO)`; `supacode/Support/MemoryWatchdog.swift` (`@MainActor + @Observable`) ticks every 5 minutes, fires `app_memory_baseline` once at 3 min uptime, then + `memory_threshold_{2048,4096,8192}mb` at most once each; 4 GB+ also goes to Sentry as a + message so it pairs with action breadcrumbs. A context-provider closure (wired in + `supacode/App/supacodeApp.swift`) attaches repo/worktree/tab counters while keeping the + watchdog TCA-agnostic. +- **Release pipeline**: `sentry-cli releases new` + `debug-files upload --include-sources + --wait` + `set-commits --auto` after archive, `releases finalize` after publish — all + best-effort (warn, never block a release), with a `SKIP_SENTRY=1` escape hatch. Lives in + the fork release script `release.sh` (see + `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`). + +## Alternatives & decisions + +- `phys_footprint` over `resident_size`: matches Activity Monitor and macOS's real memory + pressure accounting (includes compressed memory). +- Monotonic thresholds (no re-arm after a drop): the session's envelope is wanted, not event + storms from page-outs. +- Baseline at 3 min, not launch: lets first-run setup settle into a real steady state. +- 3s hang threshold over the SDK's 2s default: macOS users tolerate brief stalls + (swap/iCloud/display changes) more than iOS users. +- Hang filter conservative by design: novel all-system hang patterns still pass through, so + new noise is seen before it is filtered. +- Sentry steps never block a release: dSYMs can be re-uploaded manually later. +- PostHog autocapture off: `Application Opened`/`Backgrounded` fire on every Cmd+Tab and + would burn the free tier at ~100 DAU. + +## Amendments + +- Updated 2026-04-27: App Hang tracking removed entirely after noise analysis (the + enable → tune → remove arc) — see [002-app-hang-removal.md](002-app-hang-removal.md) +- Updated 2026-06-09: cross-system install identity on Sentry events and failed-HTTP-request + noise disabled — see [003-identity-and-network-noise.md](003-identity-and-network-noise.md) diff --git a/docs-ai/020-observability/001-action.md b/docs-ai/020-observability/001-action.md new file mode 100644 index 00000000..0437363c --- /dev/null +++ b/docs-ai/020-observability/001-action.md @@ -0,0 +1,73 @@ +# 020 — Observability: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-18 | Credentials pipeline `Config/Secrets.env` → Makefile → `Info.plist` → `Bundle`; install-UUID identity with reset-on-opt-out; Sentry tuning (traces 5%, App Hang on, no Watchdog Termination) | #207 | +| 2026-04-18 | PostHog super properties (`AnalyticsContext`), Sentry `environment` from update channel, `session_duration_seconds` on `app_quit`, all autocapture off | #208 | +| 2026-04-18 | dSYM upload (`--include-sources`) + Sentry release registration/finalize in `release.sh`, best-effort with `SKIP_SENTRY=1` | #210 | +| 2026-04-18 | `SentryEventFilter` `beforeSend` filter for system-induced App Hangs; `appHangTimeoutInterval` 2s → 3s | #211 | +| 2026-04-18 | `MemoryProbe` + `MemoryWatchdog`: baseline at 3 min, monotonic 2/4/8 GB threshold events, 4 GB+ escalated to Sentry | #212 | +| 2026-04-18 | Observability runbook documenting the whole stack | #213 | +| 2026-04-19 | `nonisolated` fix: Sentry's ANR thread calls `beforeSend` off-main, crashing the implicitly `@MainActor` filter (PROWL-MACOS-5) | #216 | +| 2026-04-22 | Cheapen release-build TCA action breadcrumbs: case-path label instead of reflection dump; watcher staggering; no-op action skip | #233 | +| 2026-04-23 | Remove App Hang tracking and delete `SentryEventFilter` after noise analysis | #236 | +| 2026-04-27 | Explicit `enableAppHangTracking = false` — #236's removal had silently reverted to the SDK default (on, 2s, unfiltered) | #241 | +| 2026-05-13 | Sentry `user.id` set to the PostHog install identifier for cross-system correlation | #284 | +| 2026-06-09 | `enableCaptureFailedRequests = false` — stop reporting third-party 5xx responses (GitHub 502s on appcast fetch) as app errors | #429 | + +The App Hang rows (#211 → #216 → #233 → #236 → #241) are a single decision arc; see +[002-app-hang-removal.md](002-app-hang-removal.md). + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- `supacode/App/supacodeApp.swift > bootstrapTelemetry` initializes both SDKs inside + `#if !DEBUG`. Sentry is gated on `crashReportsEnabled`, PostHog on `analyticsEnabled` — + two separate settings (the split comes from upstream's "advanced analytics settings", + pre-fork commit `0bf94e69`; the original PRs only mention one analytics toggle). + `infoPlistSecret` rejects empty/unsubstituted values. +- Current Sentry options: `environment` tip/production, `releaseName = "prowl@<version>"`, + `tracesSampleRate = 0.05`, `enableAppHangTracking = false`, + `enableCaptureFailedRequests = false`; `SentrySDK.setUser` with + `InstallIdentifier.current`. +- Supporting types all exist: `supacode/Clients/Analytics/AnalyticsClient.swift`, + `supacode/Support/AnalyticsContext.swift`, `supacode/Support/InstallIdentifier.swift`, + `supacode/Support/MemoryProbe.swift`, `supacode/Support/MemoryWatchdog.swift` + (defaults: `baselineDelay = 180`, `thresholdsMB = [2048, 4096, 8192]`). +- `supacode/Support/SentryEventFilter.swift` no longer exists (deleted in #236, together + with its tests). +- Release-build breadcrumbs: `supacode/Support/DebugCaseOutput.swift` (`LogActionsReducer`) + uses the cheap `releaseActionLabel` and feeds `SentrySDK.addBreadcrumb` plus + `SentrySDK.logger` in release; the reflection-based `debugCaseOutput` runs in DEBUG only. +- `release.sh` still runs the #210 sentry-cli steps, and additionally uploads the Sparkle + xcframework dSYMs (a later extension beyond #210's scope). +- Secrets flow intact: `Config/Secrets.env.template` committed, Makefile `-include + Config/Secrets.env` feeds the `archive` target, `supacode/Info.plist` carries + `ProwlSentryDSN` / `ProwlPostHogAPIKey` / `ProwlPostHogHost` placeholders. +- Tests: `supacodeTests/AnalyticsContextTests.swift`, + `supacodeTests/AppFeatureSessionDurationTests.swift`, + `supacodeTests/MemoryWatchdogTests.swift`. +- The living diagnostic runbook is [runbook.md](runbook.md) in this folder; user-facing + settings behavior is documented in `docs/reference/settings-fields.md` and + `docs/components/settings.md`. + +## Deviations from plan + +- The App Hang half of #211 was fully reversed within nine days: tracking is now off and the + filter deleted. Hang diagnosis moved to MetricKit/Instruments + (see [002-app-hang-removal.md](002-app-hang-removal.md)). +- Everything else (credentials, super properties, dSYM pipeline, memory watchdog) landed as + planned and is still in place. + +## Open questions + +- [runbook.md](runbook.md) has drifted from the code: it still documents + `enableAppHangTracking = true` + 3s threshold + `SentryEventFilter` (removed in + #236/#241) and a file-map row pointing at the filter file that no longer exists. It also + references the release script by its pre-migration `doc-onevcat/scripts/` location. Needs + an update pass during/after the docs-ai migration. +- The runbook's "16 hand-instrumented events" count dates from 2026-04-18 and was not + re-verified against today's call sites; the catalog may have drifted. diff --git a/docs-ai/020-observability/002-app-hang-removal.md b/docs-ai/020-observability/002-app-hang-removal.md new file mode 100644 index 00000000..95205e6f --- /dev/null +++ b/docs-ai/020-observability/002-app-hang-removal.md @@ -0,0 +1,61 @@ +# 020 — Amendment: App Hang tracking, enable → tune → remove + +## Context + +App Hang reporting was enabled in the initial observability wave (#207, threshold tuned in +#211). The very first hang event in Sentry was a false positive: wake-from-sleep triggered +`_NSMenuBarDisplayManagerActiveSpaceChanged` → NSWindow replicant rebuild → `mach_msg` IPC +blocking the main thread >2s — an all-AppKit stack with zero app frames, reproduced on every +laptop-lid-open. What followed was a nine-day arc where the observer itself generated most of +the work, ending in the deliberate decision to stop collecting the signal. + +## Change + +The arc, step by step: + +1. **#211 (2026-04-18) — filter + 3s threshold.** `SentryEventFilter.filterSystemHang` as + `beforeSend`: drop only when mechanism is `AppHang` AND no in-app frames AND a frame + matches a known system signature (menu-bar replicant family). Threshold raised 2s → 3s. +2. **#216 (2026-04-19) — the filter crashed the app.** The project sets + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, Sentry's ANR tracker invokes `beforeSend` + synchronously from its background detection thread, and Swift 6.2's executor check + aborted the process (`EXC_BREAKPOINT`, PROWL-MACOS-5). Fixed by marking the filter + `nonisolated`, with a compile-time regression guard (an off-main `@Sendable` test that + fails to build if `nonisolated` is dropped). +3. **#233 (2026-04-22) — the observer showed up in its own data.** Release builds were + computing reflection-based `debugCaseOutput` labels for every TCA action just to feed + Sentry breadcrumbs; hang samples even caught `-[SentryScope maxBreadcrumbs]` as a leaf + frame. Release breadcrumbs switched to a cheap enum case-path label + (`releaseActionLabel`); the PR also staggered periodic watcher work and skipped no-op + line-change actions to cut reducer/Sentry traffic. +4. **#236 (2026-04-23) — remove the signal.** Retrospective analysis of 100 sampled events + from the main dedupe bucket (PROWL-MACOS-7, 509 events / 32 users): 90% had zero app + code in their top-5 leaf frames; common leaves were `mach_msg2_trap`, `swift_retain`, + `objc_msgSend` — non-actionable kernel/runtime primitives. Of the five App-Hang-driven + changes to date, only one (#231, the main-worktree flag cache — see + [032-performance-hardening](../032-performance-hardening/000-plan.md)) was a real + performance fix; the rest were maintenance of the observer itself. Tracking config lines + and `SentryEventFilter` (+ tests) were deleted. Replacements: MetricKit + `MXHangDiagnosticPayload` via Xcode Organizer's Hangs tab, and Instruments for active + profiling. Crashes and traces unchanged. +5. **#241 (2026-04-27) — the removal was incomplete.** The Sentry Cocoa SDK defaults + `enableAppHangTracking` to **true**, so deleting the three config lines silently + reverted to tracking ON at the more sensitive 2s default with no filter — confirmed by + fresh unfiltered system-noise issues (PROWL-MACOS-61..67) on `prowl@2026.4.25`. Fixed + with an explicit `options.enableAppHangTracking = false`. + +Why this order of events matters: the decision to remove was data-driven (the 90% table), +but the lasting lesson is #241's — never turn a feature "off" by removing its enable line +without checking the SDK default. + +## Refs + +- PRs #211, #216, #233, #236, #241 +- Sentry issues: PROWL-MACOS-5 (filter crash), PROWL-MACOS-7 (noise bucket), + PROWL-MACOS-61..67 (post-#236 regression evidence) + +## Current state + +`supacode/App/supacodeApp.swift` sets `options.enableAppHangTracking = false` explicitly; +no `SentryEventFilter` exists in the tree. The cheap release-breadcrumb path from #233 +remains in `supacode/Support/DebugCaseOutput.swift`. diff --git a/docs-ai/020-observability/003-identity-and-network-noise.md b/docs-ai/020-observability/003-identity-and-network-noise.md new file mode 100644 index 00000000..13da0758 --- /dev/null +++ b/docs-ai/020-observability/003-identity-and-network-noise.md @@ -0,0 +1,34 @@ +# 020 — Amendment: Cross-system identity and HTTP-noise tuning + +## Context + +Two later, independent signal-quality fixes on the same pipeline. + +1. Cross-referencing a PostHog event (e.g. `memory_threshold_4096mb`) to its Sentry + breadcrumbs required pivoting through device + release tags, which often returned zero + matches — the two systems had no shared user key. +2. Sentry's `enableCaptureFailedRequests` (on by default) swizzles `URLSession` and turns + any 5xx response into an `HTTPClientError` event. The recurring high-priority issue + PROWL-MACOS-4 was exactly this: Sparkle fetching the appcast from GitHub Releases got a + 502, reported as *our* error. Every HTTP request Prowl makes goes to servers we don't own + (GitHub, PostHog, Sentry), so these events are pure noise. + +## Change + +- **#284 (2026-05-13)**: set Sentry `user.id` to `InstallIdentifier.current`, the same UUID + PostHog uses as `distinct_id`, so one install carries one identity across both systems. +- **#429 (2026-06-09)**: `options.enableCaptureFailedRequests = false` in the Sentry + bootstrap. Crash and error reporting unaffected; only auto-captured network-failure + events stop. + +## Refs + +- PRs #284, #429 +- Sentry issue PROWL-MACOS-4 (GitHub 502 on appcast fetch) +- Appcast infrastructure: [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md) + +## Current state + +Both present in `supacode/App/supacodeApp.swift > bootstrapTelemetry`: +`SentrySDK.setUser(Sentry.User(userId: InstallIdentifier.current))` right after +`SentrySDK.start`, and `enableCaptureFailedRequests = false` in the options block. diff --git a/docs-ai/021-sparkle-update-ux/000-plan.md b/docs-ai/021-sparkle-update-ux/000-plan.md new file mode 100644 index 00000000..865cb882 --- /dev/null +++ b/docs-ai/021-sparkle-update-ux/000-plan.md @@ -0,0 +1,78 @@ +# 021 — Sparkle Update UX: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-18 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #206, #347, #397, #498 | +| **Sources** | PR descriptions (#206, #347, #397, #498) | +| **Related** | [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md) (appcast infra: [003-appcast-from-github-releases.md](../001-fork-bootstrap-and-release-pipeline/003-appcast-from-github-releases.md)), [020-observability](../020-observability/000-plan.md), `docs/components/updates.md` | + +## Background + +Entry 001 established the delivery side of fork updates: Sparkle EdDSA signing, +date-based versions, and an appcast served from GitHub Releases. The consumption +side was stock Sparkle: an hourly background check that, on finding an update, +immediately popped a modal "Update Available" dialog. For an app whose whole point +is running long-lived agent sessions, a surprise modal interrupting work was the +wrong UX — updates should be *noticeable* but never *interrupting*. + +## Goals + +- Background update checks must never show a dialog; instead, surface availability + as a passive toolbar badge next to the notifications bell. +- Clicking the badge (or any explicit "Check for Updates…") hands off to Sparkle's + standard flow, keeping the native release-notes / install / relaunch dialogs. +- Dismissing an update ("Remind me later") must not permanently hide it — the badge + reappears on the next background cycle while the update remains available. + +### Non-goals + +- No custom in-app update UI beyond the badge; user-initiated flows stay on + `SPUStandardUserDriver` so Sparkle's dialogs, progress, and release notes are reused. + +## Design / Approach + +As designed in #206: + +- **`SilentUpdateDriver`** (`supacode/Clients/Updates/UpdaterClient.swift`): a custom + `SPUUserDriver` wrapping `SPUStandardUserDriver`. On + `showUpdateFound(userInitiated: false)` it replies `.dismiss` (so Sparkle re-offers + the update next cycle) and yields a `silentUpdateFound(version:)` event onto an + `AsyncStream`. User-initiated callbacks forward to the standard driver. +- **`UpdaterClient`** (TCA dependency): `configure` / `setUpdateChannel` / + `checkForUpdates` / `events`, owning the `SPUUpdater` singleton. +- **`UpdatesFeature`** (`supacode/Features/Updates/Reducer/UpdatesFeature.swift`): + subscribes to the event stream from `.task` (kicked off at app launch), flips + `isUpdateAvailable` and records `availableVersion`. A user-initiated check clears + the badge state first; if the update is still available, Sparkle re-triggers + `showUpdateFound` and the standard driver takes over. +- **`ToolbarUpdateButton`** (`supacode/Features/Repositories/Views/ToolbarUpdateButton.swift`): + rendered next to the notifications bell in both the worktree and canvas toolbars + when `isUpdateAvailable` is set. +- Settings keep only "Check for updates automatically"; the "Download and install + automatically" toggle was removed because Sparkle's auto-download path bypasses + `showUpdateFound` and would defeat the silent flow (`automaticallyDownloadsUpdates` + forced `false` at the time — later revisited, see amendment 003). + +## Alternatives & decisions + +- **Dismiss, don't skip**: background checks reply `.dismiss` rather than `.skip`, a + deliberate choice so the same version is re-offered every cycle instead of being + permanently suppressed (#206). +- **Reuse the standard driver for user-initiated flows** instead of building custom + update dialogs — smaller surface, native behavior preserved (#206). +- **Disable auto-download initially** (#206) because it conflicted with silent + detection; amendment 003 (#397) later restored background downloads by letting + Sparkle own the preference and teaching the silent driver about downloaded/ + installing stages. + +## Amendments + +- Updated 2026-05-25: Sparkle 2.9.2 upgrade, update-driver isolation hardening, and + Sparkle dSYM upload on release — see [002-sparkle-292-and-driver-isolation.md](002-sparkle-292-and-driver-isolation.md) +- Updated 2026-06-06: background update downloads with a ready-to-install badge + state — see [003-background-update-downloads.md](003-background-update-downloads.md) +- Updated 2026-06-24: explicit confirmation before install-and-relaunch — see + [004-install-confirmation.md](004-install-confirmation.md) diff --git a/docs-ai/021-sparkle-update-ux/001-action.md b/docs-ai/021-sparkle-update-ux/001-action.md new file mode 100644 index 00000000..2f2f7287 --- /dev/null +++ b/docs-ai/021-sparkle-update-ux/001-action.md @@ -0,0 +1,67 @@ +# 021 — Sparkle Update UX: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-18 | Silent background detection via `SilentUpdateDriver`; `ToolbarUpdateButton` badge next to the notifications bell (worktree + canvas toolbars); auto-download toggle removed, `automaticallyDownloadsUpdates` forced `false` | #206 | +| 2026-05-25 | Sparkle `2.9.0-beta.2` → `2.9.2` (exact pin); driver callbacks rewritten as plain `@MainActor` methods (dropping ~16 `MainActor.assumeIsolated` trap points); Sparkle xcframework dSYMs auto-uploaded to Sentry on release | #347 | +| 2026-06-06 | Background downloads re-enabled: Sparkle owns the auto-download preference from its standard dialog; downloaded updates surface as a ready-to-install badge state that installs/relaunches on click | #397 | +| 2026-06-24 | Explicit "Install Update and Relaunch?" confirmation before any install-and-relaunch; "Later" replies `.skip` for the current attempt without permanently skipping the version (fork issue #497) | #498 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Clients/Updates/UpdaterClient.swift` — `UpdaterClient` dependency + (`configure`, `setUpdateChannel`, `checkForUpdates`, `installDownloadedUpdate`, + `events`), `SilentUpdateDriver`, and `SparkleUpdateDelegate`. Background + `showUpdateFound` yields `.silentUpdateFound` and replies via + `silentBackgroundUpdateChoice(for:)` (`.dismiss` for not-downloaded/downloaded, + `.skip` for installing). User-initiated checks forward to `SPUStandardUserDriver`, + except the installing stage which goes straight to the confirmation alert + (`shouldConfirmInstallAndRelaunchImmediately`). `showReady(toInstallAndRelaunch:)` + always runs the `NSAlert` confirmation (`confirmInstallAndRelaunchChoice`). +- `SparkleUpdateDelegate.updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)` + captures the immediate-install handler and yields `.downloadedUpdateReadyToInstall`, + which is what drives the ready-to-install badge state. +- `supacode/Features/Updates/Reducer/UpdatesFeature.swift` — state + `isUpdateAvailable` / `isUpdateReadyToInstall` / `availableVersion`; + `activateUpdateButton` installs when ready, otherwise runs `checkForUpdates`. + Wired in `supacode/Features/App/Reducer/AppFeature.swift` (`Scope`, `.updates(.task)` + at launch, `applySettings` on settings changes; first configure triggers a + background check). +- `supacode/Features/Repositories/Views/ToolbarUpdateButton.swift` — badge with + version-aware tooltip and distinct available vs. ready-to-install wording; rendered + from two toolbar sites in `supacode/Features/Repositories/Views/WorktreeDetailView.swift`. +- `supacode/Features/Settings/Views/UpdatesSettingsView.swift` — channel picker, + "Check for updates automatically" toggle, "Check for Updates Now" button. + `automaticallyDownloadsUpdates` is not set anywhere in app code; Sparkle owns it + via the standard dialog's checkbox (per #397). +- `supacode/Commands/UpdateCommands.swift` — "Check for Updates…" menu item using the + resolved keybinding for `check_for_updates` (default ⌘⇧U per + `supacode/App/AppShortcuts.swift`); the command palette routes the same action + (`supacode/Features/App/Reducer/AppFeature+CommandPalette.swift`). +- Sparkle pinned at exact `2.9.2` in `supacode.xcodeproj/project.pbxproj` (resolved in + `supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`). + Check interval is set to 3600 s in `setUpdateChannel`. +- Tests: `supacodeTests/UpdatesFeatureTests.swift` (reducer: settings, downloaded/ + available states, badge activation) and `supacodeTests/UpdaterClientTests.swift` + (choice helpers: background installing-state, confirmation requirements). +- User-facing behavior documented in `docs/components/updates.md`. + +## Deviations from plan + +- #206 asserted `automaticallyDownloadsUpdates` must always be `false` because + auto-download bypasses the silent flow. #397 reversed this: the preference is now + Sparkle-owned, and the silent driver handles the downloaded/installing stages + instead of forbidding them. The plan's constraint no longer holds. +- #206's user-initiated path forwarded *all* `showUpdateFound` stages to the standard + driver; #498 carved out the installing stage after it was found to install-and- + relaunch without confirmation. + +## Open questions + +- `SparkleUpdateDelegate.allowedChannels(for:)` returns `[]` unconditionally (comment: + tip channel is no longer published separately), yet `UpdatesSettingsView` still + shows a Stable/Tip channel picker and `UpdatesFeature.applySettings` still threads + `UpdateChannel` through `setUpdateChannel`. The picker is effectively a no-op; + either the setting or the dead plumbing could be removed. diff --git a/docs-ai/021-sparkle-update-ux/002-sparkle-292-and-driver-isolation.md b/docs-ai/021-sparkle-update-ux/002-sparkle-292-and-driver-isolation.md new file mode 100644 index 00000000..da3b5cbe --- /dev/null +++ b/docs-ai/021-sparkle-update-ux/002-sparkle-292-and-driver-isolation.md @@ -0,0 +1,44 @@ +# 021 — Amendment: Sparkle 2.9.2 & Update-Driver Isolation (2026-05-25) + +## Context + +Sentry issue `PROWL-MACOS-AC`: a one-off `EXC_BAD_ACCESS` on a background thread +entirely inside `Sparkle.framework` (wild-pointer / use-after-free signature in the +registers), on `prowl@2026.5.20` running Sparkle `2.9.0-beta.2`. Not reproducible and +not root-causable from app frames, so the response was low-risk hardening rather +than a targeted fix. (Observability context: entry +[020-observability](../020-observability/000-plan.md).) + +## Change + +1. **Sparkle `2.9.0-beta.2` → `2.9.2`** — the app had been pinned to a January beta + of the auto-updater in notarized builds; 2.9.2 picked up three stable releases of + fixes (including a crash fix in `clearDownloadedUpdate` and a `CFRelease` + NULL-guard). +2. **`SilentUpdateDriver` isolation hardening** — `SPUUserDriver` is declared + `NS_SWIFT_UI_ACTOR` as of Sparkle 2.9, so the `nonisolated` + + `MainActor.assumeIsolated` boilerplate on every callback was replaced by plain + `@MainActor` methods, removing ~16 `assumeIsolated` trap points that would + hard-crash on any future off-main delivery. Behavior unchanged. +3. **Sparkle dSYM upload on release** — Sparkle ships as a prebuilt `binaryTarget` + xcframework, so the archive's `dSYMs/` never contains its symbols; the release + script now uploads the dSYMs bundled inside the xcframework so each Sparkle + version symbolicates on Sentry automatically (previously covered only by a one-off + manual upload on 2026-04-18). See the release runbook, + [../001-fork-bootstrap-and-release-pipeline/release-runbook.md](../001-fork-bootstrap-and-release-pipeline/release-runbook.md). + +Scope correction from the PR itself: this does not symbolicate every Sparkle frame — +for the triggering event the matching dSYM was already on Sentry, and some other +events drop the Sparkle image from `debug_meta` entirely (a sentry-cocoa limitation +no dSYM upload can recover). + +## Refs + +- PR #347 (merged 2026-05-25) + +## Current state + +Sparkle remains pinned at exact `2.9.2` (`supacode.xcodeproj/project.pbxproj`). The +`@MainActor` callback style is still in place in +`supacode/Clients/Updates/UpdaterClient.swift`, with the isolation rationale kept as +an inline comment. diff --git a/docs-ai/021-sparkle-update-ux/003-background-update-downloads.md b/docs-ai/021-sparkle-update-ux/003-background-update-downloads.md new file mode 100644 index 00000000..50a3e643 --- /dev/null +++ b/docs-ai/021-sparkle-update-ux/003-background-update-downloads.md @@ -0,0 +1,35 @@ +# 021 — Amendment: Background Update Downloads (2026-06-06) + +## Context + +#206 had disabled automatic downloads entirely (`automaticallyDownloadsUpdates` +always `false`) because Sparkle's auto-download path bypasses `showUpdateFound` and +would have defeated silent detection. That left users clicking the badge and then +waiting for the full download every time. + +## Change + +- Let Sparkle own the "automatically download and install updates in the future" + preference through its standard update window checkbox — Prowl Settings stays + scoped to automatic update *checks* only, avoiding a duplicate toggle (the app no + longer sets `automaticallyDownloadsUpdates` at all). +- Surface auto-downloaded updates as a ready-to-install toolbar state: + `SparkleUpdateDelegate.updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)` + captures the immediate-install handler and yields + `.downloadedUpdateReadyToInstall(version:)`; `UpdatesFeature` sets + `isUpdateReadyToInstall`, and clicking the badge then installs and relaunches + directly instead of re-running a check. +- Reducer coverage added for available vs. downloaded update states. + +## Refs + +- PR #397 (merged 2026-06-06) +- Supersedes the "auto-download always off" decision in [000-plan.md](000-plan.md) + +## Current state + +The two-state badge (`isUpdateAvailable` vs `isUpdateReadyToInstall`) lives in +`supacode/Features/Updates/Reducer/UpdatesFeature.swift` and +`supacode/Features/Repositories/Views/ToolbarUpdateButton.swift` (distinct tooltip +wording per state). The install path was later gated behind an explicit confirmation +— see [004-install-confirmation.md](004-install-confirmation.md). diff --git a/docs-ai/021-sparkle-update-ux/004-install-confirmation.md b/docs-ai/021-sparkle-update-ux/004-install-confirmation.md new file mode 100644 index 00000000..e5bfb41a --- /dev/null +++ b/docs-ai/021-sparkle-update-ux/004-install-confirmation.md @@ -0,0 +1,37 @@ +# 021 — Amendment: Confirmation Before Install (2026-06-24) + +## Context + +Fork issue #497: with background downloads enabled (amendment 003), a plain "Check +for Updates" could quit and relaunch the app without asking. Prowl forwards +user-initiated checks to Sparkle's standard driver; when Sparkle reports an +already-downloaded update in the *installing* stage, that path continues straight +into install-and-relaunch even though the user only asked to check. Background +silent checks could also preserve an installing state that later installs on app +termination. + +## Change + +In `supacode/Clients/Updates/UpdaterClient.swift`: + +- User-initiated `showUpdateFound` at the installing stage no longer forwards to the + standard driver; it runs an explicit "Install Update and Relaunch?" `NSAlert` + (`confirmInstallAndRelaunchChoice`) and replies `.install` only on confirmation + (`shouldConfirmInstallAndRelaunchImmediately(for:)` gates this to `.installing`). +- `showReady(toInstallAndRelaunch:)` runs the same confirmation. +- Choosing **Later** replies `.skip`, canceling the current install attempt without + permanently skipping the version, so the update is re-offered on the next check. +- Background checks at the installing stage also reply `.skip` (not `.dismiss`), via + `silentBackgroundUpdateChoice(for:)`, so a silent check cannot leave a pending + install armed for app termination. +- Focused tests for the choice helpers in `supacodeTests/UpdaterClientTests.swift`. + +## Refs + +- PR #498 (merged 2026-06-24), fixes fork issue #497 +- Behavior documented in `docs/components/updates.md` ("Install confirmation") + +## Current state + +As described; a "Check for Updates" action can no longer install and relaunch on its +own. diff --git a/docs-ai/022-tab-title-and-icon/000-plan.md b/docs-ai/022-tab-title-and-icon/000-plan.md new file mode 100644 index 00000000..9e0f26ff --- /dev/null +++ b/docs-ai/022-tab-title-and-icon/000-plan.md @@ -0,0 +1,102 @@ +# 022 — Tab Title and Icon: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-18 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #214, #215 (anchor); #234, #245, #259 (later waves); #186 (groundwork) | +| **Sources** | PR descriptions #186/#214/#215/#234/#245/#259; fork issues #172, #194; upstream review ledger entry for upstream #269 | +| **Related** | [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md) (#186 persists title/icon in the snapshot), [002-custom-commands](../002-custom-commands/000-plan.md) (#245 pins Custom Command icons), `docs/components/terminal.md` | + +## Background + +Every terminal tab in Prowl looked the same: the icon was hardcoded to `terminal` and the +title was whatever the shell last emitted via OSC 2. With many worktrees × many tabs +(often several coding agents running in parallel), multi-tab windows were visually +indistinguishable — nothing told you at a glance what a tab was *for*. + +Two pieces of groundwork already existed by the anchor date: + +- #186 (2026-04-08, fork issue #172) had added optional `title` / `icon` fields to + `SnapshotTab` in the terminal layout snapshot, so per-tab identity could round-trip + across app relaunches. That persistence work is documented in + [014-terminal-layout-persistence](../014-terminal-layout-persistence/001-action.md); + this entry covers the identity features built on top of it. +- A `promptTabTitle` NSAlert flow existed (backing Ghostty's `prompt_title` action), but + was not reachable from the tab context menu. + +Fork issue #194 then asked for the missing half: a UI to change the tab icon, noting the +snapshot layer already supported saving/restoring one. + +## Goals + +- Let the user rename a tab from the tab's right-click menu (#214). +- Let the user pick a custom tab icon — preset grid plus free-form SF Symbol name — from + the context menu and the command palette (#215). +- Make chosen titles/icons survive relaunch via the existing layout snapshot. +- (Later waves) Make icons useful without manual work: auto-detect an icon from the + running command (#234), with a sane precedence order against script/user choices + (#245); make custom titles first-class and persistent, separate from live shell titles + (#259). + +**Non-goals** + +- Per-tab tint color: fork issue #172 also mentioned persisting a tint color, but no + per-tab tint was ever built (repo-level color identity came separately, see + entry 025). + +## Design / Approach + +As planned at the anchor (from #214/#215 PR descriptions): + +- **Title change** (#214): add a "Change Tab Title..." entry at the top of the terminal + tab context menu, reusing the existing `promptTabTitle` NSAlert flow via a new + `WorktreeTerminalState.promptChangeTabTitle(_:)`. Empty input clears the override, + matching Ghostty `prompt_title` semantics. A `changeTitle` closure threads + `TerminalTabBarView` → `TerminalTabsView` → `TerminalTabsRowView` → + `TerminalTabContextMenu`. +- **Icon change** (#215): a SwiftUI picker (`TabIconPickerView`) with a curated + 40-symbol preset grid, a free-form SF Symbol name field with live preview (Done + disabled until the name resolves to a real system symbol), an "Open SF Symbols" + shortcut, and "Reset to Default". Reachable from the tab context menu and from the + command palette (`CommandPaletteItem.Kind.changeFocusedTabIcon` → + `TerminalClient.Command.presentTabIconPicker`). +- **Lock model**: the tab model gains `isIconLocked`, and `TerminalTabManager` exposes + `overrideIcon` / `clearIconOverride` / `updateIcon`, mirroring the existing + title-lock pattern (`isTitleLocked`, used by the RUN SCRIPT tab). +- **Persistence contract**: snapshot capture writes `tab.icon` only when the user has + overridden it; restore re-derives the lock from the snapshot so a chosen icon + round-trips without being silently overwritten by defaults. + +The auto-detection design (#234) and the title persistence redesign (#259) came later +and are described in the amendments. + +## Alternatives & decisions + +- **Sticky icons over reset-on-exit** (#234): an auto-detected icon deliberately stays + after the command exits — "a tab that ran `claude` keeps the Claude icon as a + 'what is this tab for' hint until the next mapped command runs". +- **Allow-list over debounce** (#234): icon detection is *mapping-hit-equals-apply* on + the first whitespace-delimited token of each OSC 2 title, with no debounce. A curated + allow-list hit is by definition brandable; this fixed short-lived commands + (`git status`) and TUIs that immediately overwrite their `preexec` title (`codex`), + both of which slipped past an earlier debounce-based detector idea. Idle shell + prompts are suppressed by a per-surface learned-idle set plus a shape heuristic. +- **Explicit precedence order** (#245): auto-detected < Run Script / Custom Command + icon < user picker. Initially two booleans; collapsed into a single + `TerminalTabIconLock` enum (`auto` / `script` / `user`) inside the same PR. +- **Custom title as separate field, not frozen live title** (#259, adapting upstream + #269): user titles moved from "override the single `title` string" to a dedicated + `customTitle` with `displayTitle = customTitle ?? title`, so the live shell title + keeps flowing underneath and a snapshot no longer freezes a stale shell title. +- **String-typed icon storage** (#234): `tab.icon` stays `String?` for back-compat with + the picker and persistence; bundled brand artwork serializes as `@asset:<Name>` and + is parsed by `ResolvedTabIcon`. + +## Amendments + +- Updated 2026-04-27: auto-detected command icons + precedence pinning (#234, #245) — + see [002-auto-detected-icons.md](002-auto-detected-icons.md) +- Updated 2026-05-08: persistent custom tab titles and inline rename (#259) — + see [003-persistent-custom-titles.md](003-persistent-custom-titles.md) diff --git a/docs-ai/022-tab-title-and-icon/001-action.md b/docs-ai/022-tab-title-and-icon/001-action.md new file mode 100644 index 00000000..52ae9e34 --- /dev/null +++ b/docs-ai/022-tab-title-and-icon/001-action.md @@ -0,0 +1,70 @@ +# 022 — Tab Title and Icon: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-08 | Groundwork: `SnapshotTab` gains optional `title`/`icon`, captured/restored with the layout snapshot (documented in entry 014) | PR #186, fork issue #172 | +| 2026-04-18 | "Change Tab Title..." added to the tab context menu, reusing the `promptTabTitle` NSAlert flow | PR #214 | +| 2026-04-18 | "Change Tab Icon..." in context menu + command palette; `TabIconPickerView` (preset grid + free-form SF Symbol name); icon lock + snapshot round-trip | PR #215, fork issue #194 | +| 2026-04-22 | Auto-detect tab icon from the running command (see amendment 002) | PR #234 | +| 2026-04-27 | Run Script / Custom Command icons pinned over auto-detection; icon-lock bools collapsed into `TerminalTabIconLock` enum (see amendment 002) | PR #245 | +| 2026-05-08 | Persistent custom tab titles: `customTitle`/`displayTitle` split, inline rename in the tab bar, context-menu entry relabeled "Rename Tab" (see amendment 003) | PR #259, upstream #269 | + +## Outcome & current state (as of 2026-07-12) + +- **Tab model** — `supacode/Features/Terminal/Models/TerminalTabItem.swift`: + `TerminalTabItem` carries `title` (live shell title), `customTitle: String?`, + `icon: String?`, `isTitleLocked: Bool`, and `iconLock: TerminalTabIconLock` + (`auto` / `script` / `user`). `displayTitle` is `customTitle ?? title`. +- **Mutation APIs** — `supacode/Features/Terminal/Models/TerminalTabManager.swift`: + `setCustomTitle`, `updateIcon` (respects locks), `overrideIcon` (user lock), + `clearIconOverride` (back to auto). The original `overrideTitle`-style title lock + survives only as `isTitleLocked` on the RUN SCRIPT tab. +- **Context menu** — `supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift`: + "Rename Tab" (hidden for title-locked tabs) and "Change Tab Icon...". The #214 label + "Change Tab Title..." no longer exists; #259 renamed it. +- **Rename routing** — the horizontal tab bar renames inline + (`supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift`, rename field + + `onRename`); Shelf spines and Canvas cards still use the modal NSAlert via + `WorktreeTerminalState.promptChangeTabTitle(_:)` in + `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift`. +- **Icon picker** — `supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift`; + palette wiring via `CommandPaletteItem.Kind.changeFocusedTabIcon` + (`supacode/Features/CommandPalette/CommandPaletteItem.swift`) and + `TerminalClient.Command.presentTabIconPicker` + (`supacode/Clients/Terminal/TerminalClient.swift`). +- **Auto-detection** — `supacode/Features/Terminal/Models/WorktreeTerminalState+TabIcons.swift` + (`noteTitleForCommandDetection`, `isLikelyIdleTitleByShape`, per-surface learned-idle + titles, focused-surface gate), `supacode/Features/Terminal/Models/CommandIconMap.swift` + (first-token map, ~66 tokens today), `supacode/Features/Terminal/Models/TabIconSource.swift` + (`@asset:` marker), `supacode/Features/Terminal/Views/TabIconImage.swift` (shared + renderer used by tab labels and Shelf spines). Brand artwork lives in + `supacode/Assets.xcassets/CommandIcons/` (44 imagesets today; grown from 19 at #234). +- **Debug surface** — the DEBUG-only Icon Catalog from #234 still exists: + `supacode/Features/Debug/Views/DebugSection.swift`, `DebugView.swift`, + `IconCatalogView.swift`, fed by `CommandIconMap.debugAllEntries`. +- **Persistence** — `supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift` + (`SnapshotTab` with `title`, `customTitle`, `icon`; v1→v2 migration promotes a v1 + `title` to `customTitle`) and + `supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift` + (capture skips blocking-script tabs, persists the icon only when `iconLock == .user`; + restore sets `iconLock = .user` iff an icon was persisted). +- **User-facing docs** — behavior is documented in `docs/components/terminal.md` + (title precedence, Rename Tab, Change Tab Icon, icon auto-detection). + +## Deviations from plan + +- The #214 modal "Change Tab Title..." flow was superseded three weeks later: #259 + replaced the context-menu label with "Rename Tab" and made the tab-bar path an inline + text field; the modal survives only for Shelf/Canvas variants. +- #215's boolean `isIconLocked` (and #245's second boolean `isScriptIconActive`) were + collapsed into the `TerminalTabIconLock` enum before #245 merged; the PR bodies + describe booleans that no longer exist. + +## Open questions + +- Fork issue #172 asked to persist tab *tint color* alongside title/icon; #186 closed + it with title+icon only and no per-tab tint exists anywhere in the model today. + Presumably dropped deliberately (repo-level color identity arrived in entry 025), but + no recorded decision was found. diff --git a/docs-ai/022-tab-title-and-icon/002-auto-detected-icons.md b/docs-ai/022-tab-title-and-icon/002-auto-detected-icons.md new file mode 100644 index 00000000..707f58bb --- /dev/null +++ b/docs-ai/022-tab-title-and-icon/002-auto-detected-icons.md @@ -0,0 +1,60 @@ +# 022 — Amendment: Auto-Detected Command Icons (#234, #245) + +## Context + +After #215, changing a tab icon was possible but entirely manual. Multi-agent windows +still defaulted to the anonymous `terminal` glyph unless the user curated every tab. +#234 (merged 2026-04-22) made icons automatic: detect the running command from the +OSC 2 title and paint a per-command icon — branded artwork where available, SF Symbol +fallback otherwise. #245 (merged 2026-04-27) then fixed the precedence gap this opened +for Run Script and Custom Command tabs. + +## Change + +**#234 — detection pipeline and assets** + +- Detection runs in `WorktreeTerminalState.noteTitleForCommandDetection` on every OSC 2 + title change. *Mapping-hit-equals-apply*: `CommandIconMap` looks up the first + whitespace-delimited token; a hit applies immediately, a miss leaves the current icon + alone. No debounce — the curated allow-list makes a hit trustworthy, which handles + short-lived commands (`git status`) and TUIs that rewrite their title right away + (`codex` → repo name). +- Idle-prompt suppression: a per-surface learned-idle set captures the first title after + each `command_finished`, plus a shape heuristic (`isLikelyIdleTitleByShape`) for the + bootstrap window before anything has been learned. +- Sticky semantics: the icon stays after the command exits, as a "what is this tab for" + hint, until the next mapped command runs. Manual overrides (`isIconLocked` at the + time) always win, and only the focused surface of a multi-split tab may drive the + tab's icon. +- `TabIconSource` (required SF Symbol fallback + optional asset name), `@asset:<Name>` + serialization parsed by `ResolvedTabIcon`, and a shared `TabIconImage` view used by + both `TerminalTabLabelView` and `ShelfSpineView`. Shipped ~55 first-token mappings + across 14 categories with 19 monochrome template brand SVGs + (sources listed in `supacode/Assets.xcassets/CommandIcons/README.md`). +- DEBUG-only Debug Window with an Icon Catalog section rendering every map entry + through `TabIconImage`, with a searchable filter. + +**#245 — precedence pinning** + +- New precedence level: auto-detected < script/Custom Command < user picker. Run Script + tabs keep `play.fill` for the tab's lifetime (no single-frame flash before the + command icon took over); Custom Commands carry their configured `systemImage` via a + new `customCommandIcon` parameter on `TerminalClient.Command.createTabWithInput` / + `createSplitWithInput`. The model's `"terminal"` placeholder and empty values count + as "unset" so untouched commands still get full auto-detection. +- Within the same PR, the two lock booleans (`isIconLocked`, `isScriptIconActive`) were + collapsed into a single `TerminalTabIconLock` enum (`auto`/`script`/`user`), commit + `1826028c`. Resetting via the picker clears the lock back to `auto` so detection can + take over again. + +## Refs + +- PR #234 (2026-04-22), PR #245 (2026-04-27, incl. `1826028c`) +- Cross-link: [002-custom-commands](../002-custom-commands/000-plan.md) for the Custom + Command model that `customCommandIcon` reads from. + +## Current state + +All of the above is live; see the file inventory in [001-action.md](001-action.md). +The command map and artwork kept growing after #234 (about 66 tokens and 44 brand +imagesets as of 2026-07-12, e.g. Cline, Kimi). diff --git a/docs-ai/022-tab-title-and-icon/003-persistent-custom-titles.md b/docs-ai/022-tab-title-and-icon/003-persistent-custom-titles.md new file mode 100644 index 00000000..acb2b99c --- /dev/null +++ b/docs-ai/022-tab-title-and-icon/003-persistent-custom-titles.md @@ -0,0 +1,39 @@ +# 022 — Amendment: Persistent Custom Tab Titles (#259) + +## Context + +Until May 2026 a user-set title simply overrode the tab's single `title` string (the +#214 model). That conflated two things: the live shell title (OSC 2, constantly +updated) and the user's chosen name. Snapshots persisted whatever string was current, +so restores could freeze a stale shell title as if the user had chosen it. Upstream +solved this with dedicated custom titles (upstream #269, commit `6615f49c`); fork +PR #259 (merged 2026-05-08) adapted that design onto the fork's own tab model and +snapshot pipeline. + +## Change + +- Split live shell titles from user titles: `TerminalTabItem.customTitle: String?` + alongside `title`, with `displayTitle = customTitle ?? title`. The shell keeps + updating `title` underneath a custom name. +- Inline rename for the normal tab bar (text field inside the tab, + `TerminalTabView`); Shelf and Canvas keep the existing modal NSAlert path + (`promptChangeTabTitle`). The context-menu entry was relabeled from + "Change Tab Title..." to "Rename Tab" (commit `f72ed4cd`), hidden for title-locked + tabs (RUN SCRIPT). +- Persistence: `SnapshotTab` gains `customTitle`; a v1→v2 payload migration promotes a + v1 `title` to `customTitle` (a v1 snapshot could not distinguish the two, and + promoting preserves what the user saw). Display titles also surface in + Canvas/Shelf/CLI snapshots. +- `TerminalTabManager.setCustomTitle` replaced the old title-override entry points. + +## Refs + +- PR #259 (2026-05-08); upstream reference supabitapp/supacode `6615f49c` (upstream #269) +- Cross-link: [014-terminal-layout-persistence](../014-terminal-layout-persistence/000-plan.md) + for the snapshot pipeline this migration lives in. + +## Current state + +Live as described; key files: `supacode/Features/Terminal/Models/TerminalTabItem.swift`, +`TerminalTabManager.swift`, `TerminalLayoutSnapshotPayload.swift` (migration comment at +the top of the payload), `supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift`. diff --git a/docs-ai/023-shelf-mode/000-plan.md b/docs-ai/023-shelf-mode/000-plan.md new file mode 100644 index 00000000..c12550d4 --- /dev/null +++ b/docs-ai/023-shelf-mode/000-plan.md @@ -0,0 +1,121 @@ +# 023 — Shelf Mode: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-21 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #230, #232, #244, #246, #272, #273, #356, #432, #434 | +| **Sources** | `doc-onevcat/shelf-view.md` (absorbed here; original removed in the docs-ai migration), PR descriptions, [jank-investigation.md](jank-investigation.md) (kept verbatim in this folder) | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [012-keybinding-system](../012-keybinding-system/000-plan.md), [025-repo-identity-appearance](../025-repo-identity-appearance/000-plan.md), [030-agent-status-detection](../030-agent-status-detection/000-plan.md), `docs/components/shelf.md`, `docs/components/view-modes.md` | + +## Background + +Canvas (entry 005) spreads worktrees out as flat per-tab cards and deliberately weakens +the worktree concept. Shelf is the opposite bet: a terminal presentation mode that +preserves and strengthens it. Each worktree (or plain folder) becomes a "book" with a +one-line-wide vertical spine that doubles as its tab bar. Exactly one book is open at a +time, occupying the space between a left stack of already-passed spines and a right stack +of upcoming ones: + +``` +[ left spine stack ] [ open book terminal area ] [ right spine stack ] +``` + +The design was written up front as a full spec (spine geometry, animation, keyboard +shortcuts, notification badges) with an "Implementation Decisions Journal" appended +during the build; both are condensed into this entry. + +## Goals + +- A terminal-region presentation mode next to Canvas, **mutually exclusive** with Canvas + and Archived Worktrees; the left navigation stays visible. +- **Book = worktree or plain folder**, 1:1. Book order mirrors sidebar order; reordering + happens through the sidebar, not on the shelf. +- **Spine = vertical tab bar + identity**: rotated worktree/branch header (branch line + omitted for plain folders), icon-only tab slots, and — on the open book's spine only — + pinned bottom controls (`+` / vertical split / horizontal split). Holding ⌘ swaps each + slot's icon for its `Cmd+1..9` digit with zero layout shift. +- **`selectedWorktreeID` is the single source of truth** for the open book, giving + bidirectional sync with the sidebar for free. Unlike Canvas, clicking a sidebar + worktree does **not** exit Shelf — it just turns to that book. +- **Book set = opened worktrees only**: only worktrees with terminal state (interacted + with this session) get spines; clicking an unopened worktree materializes its spine. +- Snappy spine-flow animation (~200 ms ease-in-out); forward clicks pull spines into the + left stack, backward clicks push them back to the right stack, with the outgoing + terminal crossfading so no half-clipped surface is ever visible. +- Notification surfacing at two levels: per-tab slot tint (same token as Canvas title-bar + highlights) plus an aggregated dot on the spine header for tabs scrolled out of view. +- All shortcuts configurable through the keybinding system (entry 012): `Toggle Shelf` + (⌘⇧↩, symmetric with Canvas's ⌥⌘↩), next/previous book (⌘⌃→/←), and direct book jump + (⌃⌥1..9, deliberately distinct from ⌃1..9 worktree selection because plain folders + interleave in book numbering). + +**Non-goals**: no Shelf-only tab memory (the open book's active tab is the worktree's +active tab); no book reordering on the shelf; no spines for never-opened worktrees. + +## Design / Approach + +- **State**: `RepositoriesFeature.State.isShelfActive: Bool` as an independent flag, not + a `SidebarSelection` case — Shelf still needs `selection` to track the open book, and a + separate flag makes the sidebar sync fall out for free. Entering Canvas or Archived + clears the flag. +- **Model**: `ShelfBook` unifies worktrees and plain folders behind `Worktree.ID`; plain + folders use the repository ID, matching the synthetic worktree from + `selectedTerminalWorktree`, so `openShelfBookID == selectedTerminalWorktree?.id` holds + for both kinds without special-casing. +- **Book membership**: `RepositoriesFeature.State.openedWorktreeIDs: Set<Worktree.ID>`, + inserted from `selectWorktree` / `selectRepository` / the `toggleShelf` entry path, plus + a `markWorktreeOpened` catch-all that AppFeature dispatches on + `.terminalEvent(.tabCreated)` — covering cold-launch auto-selection, layout restore, and + any other path that sets `selection` directly. `orderedShelfBooks()` filters the live + repository list against this set (so archived/removed worktrees drop off even if their + ID lingers in the set). +- **Views** (`supacode/Features/Shelf/`): `ShelfView` lays out spines and the open area; + `ShelfSpineView` + `ShelfSpineTabSlot` render one spine; `ShelfOpenBookView` is a + leaner alternative to `WorktreeTerminalTabsView` that renders the terminal content + stack without the horizontal tab bar (in Shelf the tab bar *is* the spine). +- **Animation**: as shipped in #230, spines lived in left/right stacks bridged with + `matchedGeometryEffect`; the root `HStack` carries + `.animation(.easeInOut(duration: 0.2), value: openBookID)` so sidebar-originated + switches animate identically to spine clicks. (The stack model was later replaced — see + [002-book-switch-jank.md](002-book-switch-jank.md).) +- **Close last tab retires the book**: `TerminalClient.Event.tabClosed` gained a + `remainingTabs: Int` payload; on `remainingTabs == 0` AppFeature dispatches + `.repositories(.markWorktreeClosed(id))`, which removes the ID from + `openedWorktreeIDs` and — only while Shelf is active and the closed book was open — + auto-advances selection to a neighboring book. +- **Commands plumbing**: Shelf menu commands merged into `SidebarCommands` rather than a + new `Commands` struct, because SwiftUI's `@CommandsBuilder` caps direct children in a + `.commands { }` block and a new top-level struct broke the build. + +## Alternatives & decisions + +- **Wrapper commands over multi-binding**: the spec wanted ⌘⌃→/← as a *second* binding on + `selectNext/PreviousWorktree`, requiring the keybinding schema to grow from a single + `shortcut` to a collection. Implemented instead as distinct `selectNextShelfBook` / + `selectPreviousShelfBook` commands: book order includes interleaved plain folders, so + "next book" is not semantically "next worktree", and the wrapper approach keeps + `AppShortcut.Binding.shortcut` singular. +- **Close-last-tab behavior reversed mid-branch**: earlier drafts kept an empty book on + the shelf with a placeholder terminal area. Reversed before merge (commit `795776e9`): + a lingering empty book felt unnatural and was dead weight. (PR #230's test plan text + still describes the pre-reversal behavior; the spec journal records the reversal.) +- **"Remove Book" replaced by "Close Worktree/Folder"** (#232, one day after launch): the + original spine context-menu entry conflated "take this book off the Shelf" with + destructive resource lifecycle (it archived worktrees, silently no-op'd on the main + worktree, and removed plain folders from the app). The kind-aware Close action reuses + the close-last-tab pipeline and drops the `.destructive` role — it is view-state, not + data deletion. +- **`openedWorktreeIDs` is additive by design** (v1): stale IDs are tolerated because + book iteration anchors on the live `repositories` array; pruning can be layered in + later if the set grows unbounded. + +## Amendments + +- Updated 2026-04-29: book-switch jank investigation and retained performance fixes + (#246) — see [002-book-switch-jank.md](002-book-switch-jank.md) and the verbatim trace + record [jank-investigation.md](jank-investigation.md) +- Updated 2026-06-10: agent status badges on spines + trackpad book navigation + (#432/#434), and the 2026-06-12 herdr-review decision to keep the 3-second status hold — + see [003-agent-status-and-trackpad.md](003-agent-status-and-trackpad.md) diff --git a/docs-ai/023-shelf-mode/001-action.md b/docs-ai/023-shelf-mode/001-action.md new file mode 100644 index 00000000..2005ed9c --- /dev/null +++ b/docs-ai/023-shelf-mode/001-action.md @@ -0,0 +1,74 @@ +# 023 — Shelf Mode: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-21 | Shelf shipped: books/spines/open-book layout, spine-flow animation, ⌘-held hotkey glyphs, notification tint + header dot, new configurable shortcuts (⌘⇧↩, ⌘⌃→/←, ⌃⌥1..9). Late in the branch, close-last-tab was reversed to retire the book (`795776e9`) | PR #230 | +| 2026-04-22 | Spine context menu "Remove Book" replaced by kind-aware "Close Worktree" / "Close Folder" reusing the close-last-tab pipeline; plain-folder close paths covered by tests (`662e6b4d`) | PR #232 | +| 2026-04-28 | Per-button tooltips on spine bottom controls (`New Tab (⌘T)` style via `GhosttyShortcutManager`); book-level `.help` moved to the header so it stopped masking every control below | PR #244 | +| 2026-04-29 | Book-switch performance wave: bare-⌘/⌃ shortcut-hint mode, sidebar key forwarding disabled in Shelf, single-`ForEach` spine layout (drops `matchedGeometryEffect`), open-book opacity transition removed, permanent signposts. See [002-book-switch-jank.md](002-book-switch-jank.md) | PR #246 | +| 2026-04-29 | Sidebar type-through key forwarding removed entirely (replaced by refocusing the terminal on selection), superseding #246's Shelf-only gate | commit `9030147d` | +| 2026-05-09 | Cmd-W ownership held on the terminal layer through a book switch: new `shelfHasOpenBooks` signal into `WindowCloseShortcutPolicy` so auto-repeated ⌘W chewing through tabs across book boundaries no longer closes the window in the one-frame gap where no close target exists | PR #272 | +| 2026-05-09 | ⌘-held `⌘N` glyphs, dim-on-⌘ for slots 10+, and the glyph/close-button trade-off scoped to the **open** book's spine only — closed books stop advertising hotkeys they can't service | PR #273 | +| 2026-05-27 | Shelf spine tint preferences: Neutral / System Tint fallback + "Follow Repo Color" toggle (default preserves prior behavior: neutral fallback, follow enabled) | PR #356 | +| 2026-06-10 | Agent status badges on spines + two-finger trackpad book switching (community `[codex]` PR); review pass restored wrap-around navigation and resynced detection on repository-list changes. See [003-agent-status-and-trackpad.md](003-agent-status-and-trackpad.md) | PRs #432, #434 | + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- **Views/model**: `supacode/Features/Shelf/` — `Models/ShelfBook.swift`, + `Views/ShelfView.swift` (also hosts `ShelfSwipeEventMonitor` for trackpad switching), + `Views/ShelfSpineView.swift` (contains `ShelfSpineTabSlot`, `ShelfMetrics`, and the + agent-status marker), `Views/ShelfOpenBookView.swift`, `Views/ShelfSidebarButton.swift`. +- **State/reducer**: `isShelfActive` and `openedWorktreeIDs` on + `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`; `toggleShelf`, + `selectNext/PreviousShelfBook`, `selectShelfBook(Int)`, `markWorktreeOpened/Closed` in + `RepositoriesFeature+CoreReducer.swift`; `orderedShelfBooks()`, `shelfBook(atOffset:)`, + and `replacementBookAfterClosing` in `RepositoriesFeature+Selection.swift`. +- **Close pipeline**: `tabClosed(worktreeID:remainingTabs:)` handled in + `supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift`, dispatching + `markWorktreeClosed` at `remainingTabs == 0`. Auto-advance picks the neighbor *after* + the closed book, else the one before (a refinement over the plan's "next remaining + book"). +- **Shortcuts**: command IDs (`toggle_shelf`, `select_next_shelf_book`, …, + `select_shelf_book_1..9`) in `supacode/App/AppShortcuts.swift`; menu plumbing in + `supacode/Commands/SidebarCommands.swift`; `⌘⌃→/←` are now dual-purpose — when Canvas is + showing, `selectNext/PreviousShelfBook` reroutes to Canvas spatial navigation + (`requestCanvasCommand(.navigate(...))`, from entry 024's spatial-navigation work). +- **Cmd-W policy**: `shelfHasOpenBooks` threaded through `WindowCloseShortcutPolicy` in + `supacode/Commands/WindowCommands.swift`. +- **Tint settings**: `shelfSpineTintFallback` / `shelfSpineTintFollowsRepositoryColor` in + `supacode/Features/Settings/Models/GlobalSettings.swift`, backed by + `supacode/Features/Settings/Models/ShelfSpineTintFallback.swift`. +- **Agent status**: `showActiveAgentStatusInShelf` setting; the status values come from + the detection subsystem (entry 030), whose 3-second working hold lives at + `supacode/Domain/AgentDetection/PaneAgentState.swift` (`workingStateHold`). +- **Jank-fix survivors**: `CommandKeyObserver.shouldShowShortcuts(for:)` in + `supacode/App/CommandKeyObserver.swift` still gates hint mode to bare ⌘/⌃; the + single-`ForEach` layout is current. The "sidebar `.onKeyPress` disabled while Shelf is + active" fix from #246 no longer exists as such — the type-through forwarding was removed + wholesale by `9030147d`, so there is nothing left to gate. +- **Tests**: `supacodeTests/ShelfFeatureTests.swift`, + `supacodeTests/ShelfBookOrderingTests.swift`, plus `WindowCloseShortcutPolicyTests` + coverage for the ⌘W hold. +- **Behavior docs**: `docs/components/shelf.md`, `docs/components/view-modes.md`. + +## Deviations from plan + +- Multi-binding aliasing for ⌘⌃→/← was dropped in favor of dedicated Shelf-book commands + (recorded as a decision in [000-plan.md](000-plan.md)). +- Close-last-tab keeps-empty-book behavior was reversed to retire-the-book before #230 + merged; the PR's own test-plan text still describes the older behavior. +- The `matchedGeometryEffect` left/right spine-stack layout shipped in #230 was replaced + a week later by a single-`ForEach` layout for performance (#246). +- #432 accidentally replaced the long-standing keyboard wrap-around for book navigation + with bounded edges; #434 restored the wrap for both keyboard and the new swipe gesture. + +## Open questions + +- PR #230's merged description advertises `.transition(.opacity)` for the open-book + crossfade and the two-stack `matchedGeometryEffect` layout; both were removed in #246. + Anyone reading the PR as a design reference should prefer this entry + + [jank-investigation.md](jank-investigation.md). diff --git a/docs-ai/023-shelf-mode/002-book-switch-jank.md b/docs-ai/023-shelf-mode/002-book-switch-jank.md new file mode 100644 index 00000000..605f2cc8 --- /dev/null +++ b/docs-ai/023-shelf-mode/002-book-switch-jank.md @@ -0,0 +1,51 @@ +# 023 — Amendment: Book-Switch Jank Investigation (2026-04-29) + +## Context + +A week after launch, quickly switching Shelf books — especially via keyboard shortcuts — +showed visible frame drops. Early Instruments captures included multi-second main-thread +hangs and hundreds of thousands of SwiftUI update/cause edges per short recording. The +reducer and Ghostty bridge signposts were consistently sub-millisecond: the cost was +SwiftUI invalidation, layout, responder, and display-list work. + +The full investigation — trace methodology (`xctrace` exports, hitches normalized per +`reducer.selectWorktree` signpost), the run-by-run table, and the rejected experiments — +is kept verbatim in this folder as [jank-investigation.md](jank-investigation.md). This +amendment records only the outcome. + +## Change + +Retained fixes (PR #246, plus `0fe682cb` from the investigation branch): + +- Sidebar tab-count reads moved into a leaf view (`RepoHeaderTabCountBadge`) so unrelated + terminal activity stops invalidating every repository section — the single largest win. +- `orderedShelfBooks()` rewritten without per-body `Dictionary`/`Set`/row-model churn. +- Shelf-originated switches stop sending a redundant TCA animation transaction on top of + the view-level `.animation(value: openBookID)`. +- `CommandKeyObserver` enters shortcut-hint mode only for bare ⌘ or bare ⌃, not for every + chord containing them. +- Sidebar type-through `.onKeyPress` forwarding not installed while Shelf is active. +- Spines rendered from a single `ForEach`, removing the cross-stack + `matchedGeometryEffect` from the original two-stack layout. +- Open-book `.transition(.opacity)` removed (small win, no observed UX cost). +- Permanent signposts under `com.onevcat.prowl` / PointsOfInterest for future regressions. + +Rejected (tried, measured, reverted — do not re-try without a new trace): removing the +outer `.id(worktree.id)`, disabling/isolating the spine animation, rendering the terminal +in a final-position overlay (black-edge artifact), and conditionally removing closed-spine +context menus (worse traces *and* worse UX). + +## Refs + +- PR #246 (2026-04-29), commit `0fe682cb` +- [jank-investigation.md](jank-investigation.md) — full trace record, methodology, and + future structural directions (custom spine layout, virtualized spines, …) + +## Current state + +The single-`ForEach` layout and the bare-⌘/⌃ hint gate are still in place +(`supacode/Features/Shelf/Views/ShelfView.swift`, `supacode/App/CommandKeyObserver.swift`). +The Shelf-only `.onKeyPress` gate is gone because the sidebar type-through forwarding was +removed entirely the same day (commit `9030147d`, "refocus terminal on single selection"), +which also removes the fix's behavior trade-off (typed characters not forwarded while +Shelf was active). diff --git a/docs-ai/023-shelf-mode/003-agent-status-and-trackpad.md b/docs-ai/023-shelf-mode/003-agent-status-and-trackpad.md new file mode 100644 index 00000000..e8041b83 --- /dev/null +++ b/docs-ai/023-shelf-mode/003-agent-status-and-trackpad.md @@ -0,0 +1,51 @@ +# 023 — Amendment: Agent Status Badges + Trackpad Navigation (2026-06-10) + +## Context + +By June the Active Agents panel and per-pane agent detection existed +([030-agent-status-detection](../030-agent-status-detection/000-plan.md)), but Shelf +spines showed no agent state — checking whether a book's agent was working meant opening +it or the panel. Separately, Shelf book switching had no trackpad affordance. + +## Change + +- **Agent status badges** at the bottom of each spine, grouped by worktree; agent + detection stays enabled while Shelf is visible even when the Active Agents panel is + hidden (a resync was added in review for the repository-list-emptied edge case). + Gated by the `showActiveAgentStatusInShelf` setting. +- **Two-finger horizontal trackpad switching** between books, one switch per gesture + (`ShelfSwipeEventMonitor` in `supacode/Features/Shelf/Views/ShelfView.swift`). +- The community PR (#432, `[codex]`) changed book navigation to bounded edges, which + silently removed the long-standing keyboard wrap-around because `shelfBook(atOffset:)` + is shared by ⌘⌃←/→ and the new gesture. The review pass (#434, which merged #432's + commits unchanged) restored wrap-around for both input paths and re-added the wrap + tests. + +## Decision: keep the 3-second status hold; do not port the herdr refactor + +The badge values come from the agent detection subsystem, which was originally ported +from herdr v0.5.6 (see entry 030). Shortly after the badges shipped, #438 (2026-06-13) +stabilized status flicker by widening the *working* hold to 3 seconds for all agents +(`workingStateHold` in `supacode/Domain/AgentDetection/PaneAgentState.swift`). + +A 2026-06-12 review of herdr upstream (to v0.6.10/HEAD) found that herdr had meanwhile +abandoned fixed-duration holds for a heavier scheme: bare-idle vs visible-idle splitting +with confirmation rescans, OSC-only working evidence for Claude/Codex, and per-agent TOML +manifests. Deliberate fork decision (2026-06-13): the 3-second hold is satisfactory in +practice — keep the simple approach and **do not port** the herdr refactor unless false +reports recur at 3 s. The reviewed-but-not-ported material is archived with the upstream +review notes; the detection story itself belongs to entry 030. + +## Refs + +- PRs #432 (community), #434 (review pass; merged both), #438 (hold widening, entry 030) +- [030-agent-status-detection](../030-agent-status-detection/000-plan.md) +- [029-active-agents-panel](../029-active-agents-panel/000-plan.md) + +## Current state + +Badges render in `supacode/Features/Shelf/Views/ShelfSpineView.swift` +(`ShelfMetrics.agentStatusMarkerSize`); the setting lives in +`supacode/Features/Settings/Models/GlobalSettings.swift` +(`showActiveAgentStatusInShelf`). Wrap-around navigation and the swipe gesture are +covered by `supacodeTests/ShelfFeatureTests.swift`. diff --git a/docs-ai/024-canvas-interaction-evolution/000-plan.md b/docs-ai/024-canvas-interaction-evolution/000-plan.md new file mode 100644 index 00000000..d9d3910a --- /dev/null +++ b/docs-ai/024-canvas-interaction-evolution/000-plan.md @@ -0,0 +1,116 @@ +# 024 — Canvas Interaction Evolution: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-25 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #226, #229, #238, #329, #337, #362, #393, #394, #395, #396, #400, #401, #402, #457, #507, #509, #514 | +| **Sources** | PR descriptions; fork issues #197, #225, #228, #328, #357, #392, #453; community PR #358 (vince-hz) | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [011-canvas-multiselect-broadcast](../011-canvas-multiselect-broadcast/000-plan.md), [043-canvas-tile-layout](../043-canvas-tile-layout/000-plan.md), [002-custom-commands](../002-custom-commands/000-plan.md), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md), `docs/components/canvas.md`, `docs/components/view-modes.md` | + +## Background + +Canvas v1 ([005](../005-canvas-live-sessions/000-plan.md)) shipped the free-form +live-card view in March 2026. Once it became the daily driver for watching multiple +agents, friction accumulated against the Normal view: navigation was trackpad-only +(pinch zoom, two-finger pan), acting on a card required focusing it first, card layout +was lost across launches, the toolbar/palette/sidebar all silently kicked the user back +to Normal view, and keyboard coverage was near zero. + +There was never a single master plan. The work arrived as a three-month stream of +user-filed fork issues and PR-level designs (2026-04-20 → 2026-06-27), all pushing one +theme: **Canvas should be a first-class primary view — anything you can do in Normal +view should work without leaving Canvas.** This entry records that program, anchored at +the April pointer-interaction wave; the later waves are amendments. Layout-algorithm +work is out of frame ([043](../043-canvas-tile-layout/000-plan.md)), as is multi-select +broadcast ([011](../011-canvas-multiselect-broadcast/000-plan.md)). + +## Goals + +- Mouse-first navigation parity: zoom and pan without a trackpad (fork issue #197). +- Act on a card (close, expand) without focusing it first (fork issue #225). +- Never steal scroll events from TUIs that speak the mouse protocol (fork issue #228). +- Card layout and z-order survive relaunch (fork issue #328); Canvas can be the boot + view. +- App actions — new tab, Run Script, custom commands, code-host/PR actions, + sidebar/palette selection — resolve against the *focused Canvas card* instead of + forcing an exit to Normal view. +- Keyboard coverage: arrange/organize, expand a card, spatial card-to-card navigation. + +**Non-goals**: changing the packing algorithms (Waterfall/MaxRects stayed as v1 built +them until [043](../043-canvas-tile-layout/000-plan.md)'s Tile layout), and broadcast +semantics. + +## Design / Approach (anchor wave, April 2026) + +- **Cmd+wheel zoom** (#238): held Cmd routes wheel events to + `CanvasScrollCoordinator.handleZoom`; the math lives in `CanvasZoomMath`, extracted + from the existing `MagnifyGesture` anchor-preserving formula, with sensitivity tuned + per `NSEvent.hasPreciseScrollingDeltas` (mouse wheel vs trackpad). The Cmd+scroll + path is wired into the existing pan-momentum monitor so flipping Cmd mid-gesture + switches behavior immediately. +- **Middle-click pan** (#238): a window-scoped `NSEvent` local monitor installed by + `CanvasScrollContainerView` while it is in a window intercepts + `otherMouseDown/Dragged/Up` with `buttonNumber == 2`, drives the canvas offset + directly, and swallows the events so focused Ghostty surfaces never see them. The + monitor tears down with the view, so it has no effect outside Canvas. +- **Hover card actions** (#226): per-card close/expand buttons fade in on title-bar + hover. Closing the highlighted card auto-advances selection to the nearest surviving + neighbor in the pre-close tab order, matching the terminal's own focus handoff. +- **Scroll ownership rule** (#229): the canvas must never claim wheel events the + terminal wants. #204's "no-scrollback passthrough" (forward scroll to the canvas when + Ghostty reports empty scrollback) was reverted because TUIs (pagers, editors) drive + their own mouse scroll protocol with an empty scrollbar. + +## Program-level design theme (later waves) + +Two mechanisms carry almost all subsequent work: + +- **Focused-card action routing**: `AppFeature` resolves an action-target worktree that + falls back from the Normal selection to the Canvas-focused card + (`actionTargetWorktree` → `canvasFocusedTerminalWorktree` in + `supacode/Features/App/Reducer/AppFeature+Support.swift`). New tab (#394), custom + actions (#362), and code-host/PR actions (#509) all ride this. +- **One-shot reducer→view requests**: Canvas operations that are view-local (focus a + card, expand, arrange) are triggered from reducers via `CanvasFocusRequest` / + `CanvasCommandRequest` values that `CanvasView` observes and consumes exactly once + (#395, #396, #402). + +## Alternatives & decisions + +- **#229 partial revert**: of #204's three scroll optimizations, only the + no-scrollback passthrough branch was dropped; gesture continuity and the 0.3 s bounce + window were kept. +- **#226 deferred the context-menu variant** deliberately; it landed later as #457. +- **#393 keybinding choice**: the proposed `Cmd+Shift+R` was rejected (taken by + `refreshWorktrees`); Arrange/Organize joined the existing Canvas `⌘⌥` family as + `⌘⌥R` / `⌘⌥G`, user-rebindable via the Shortcuts recorder. +- **#362 single-item toolbar cluster**: the Run + Custom Command cluster renders as one + `ToolbarItem { HStack }` — an intentional divergence from the Normal toolbar — because + on Canvas the host view stays mounted and NSToolbar animated per-item insert/remove + when switching between cards with different command counts. +- **#401 adaptive default card size** replaced the fixed 1000×680 card with clamped + linear interpolation on host-screen width (800×550 at ≤1512 pt), so small screens get + a higher fit-to-view scale and readable text. +- **#402 expand-in-place** replaced #226's expand-to-tab-view stopgap. The animation is + driven by an `Animatable` container (`AnimatedExpandableCard`) so size/center/scale + interpolate from one progress value per frame; the canvas transform is never mutated, + which is what keeps the background frozen. Earlier attempts using implicit + per-modifier interpolation or plain `@State` progress did not animate correctly. +- **#514 spatial navigation** uses weighted distance (primary axis + 2× cross axis) to + prefer directly aligned neighbors over diagonal ones; in the reducer, the `⌘⌃`-arrow + worktree-selection actions are redirected into Canvas navigate commands while Canvas + is active, so the keys no longer force a view-mode switch. + +## Amendments + +- Updated 2026-05-28: Canvas becomes a first-class view — layout persistence, Default + View option, focused-card custom actions — see + [002-first-class-canvas.md](002-first-class-canvas.md) +- Updated 2026-06-06: keyboard & layout wave — shortcuts, in-canvas navigation + routing, resize animation, adaptive sizing, expand-in-place — see + [003-keyboard-and-layout-wave.md](003-keyboard-and-layout-wave.md) +- Updated 2026-06-27: completeness wave — card tab context menu, hover help, code-host + toolbar actions, spatial card navigation — see + [004-completeness-wave.md](004-completeness-wave.md) diff --git a/docs-ai/024-canvas-interaction-evolution/001-action.md b/docs-ai/024-canvas-interaction-evolution/001-action.md new file mode 100644 index 00000000..d6af2c97 --- /dev/null +++ b/docs-ai/024-canvas-interaction-evolution/001-action.md @@ -0,0 +1,97 @@ +# 024 — Canvas Interaction Evolution: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-20 | Hover close/expand buttons on card title bars; closing the highlighted card auto-advances selection to the nearest surviving neighbor (fork issue #225) | PR #226 | +| 2026-04-20 | Revert #204's no-scrollback scroll passthrough so TUIs with their own mouse protocol scroll again; gesture continuity + bounce window kept (fork issue #228) | PR #229 | +| 2026-04-25 | Cmd+wheel zoom (`CanvasScrollCoordinator.handleZoom` / `CanvasZoomMath`) and middle-click drag pan via window-scoped `NSEvent` monitor (fork issue #197) | PR #238 | +| 2026-05-24 | Persist Canvas card layout and z-order across launches (`CanvasLayoutStore`); no auto-arrange over restored layouts; `UserDefaults` format migration — see [002](002-first-class-canvas.md) | PR #329 | +| 2026-05-25 | Canvas added to Settings → Appearance → Default View; boots straight into Canvas (falls back to Normal with zero worktree rows) — see [002](002-first-class-canvas.md) | PR #337 | +| 2026-05-28 | Canvas custom actions: Run/Stop Script + Custom Commands routed through the focused card; atomic settings sync on card switch; single-item toolbar cluster (community #358 by vince-hz + refinements) — see [002](002-first-class-canvas.md) | PR #362 (shared with [002-custom-commands](../002-custom-commands/001-action.md)) | +| 2026-06-05 | New Terminal/New Tab route through the active terminal target so Canvas targets the focused card's worktree; Ghostty terminal commands in the palette with a Canvas target — see [003](003-keyboard-and-layout-wave.md) | PR #394 | +| 2026-06-05 | Arrange (`⌘⌥R`) / Organize (`⌘⌥G`) keyboard shortcuts, user-rebindable (fork issue #392) — see [003](003-keyboard-and-layout-wave.md) | PR #393 | +| 2026-06-05 | Sidebar repo/worktree/folder rows and Active Agents entries keep Canvas selected; `CanvasFocusRequest`/`CanvasFocusResolver` create-or-focus cards and center them — see [003](003-keyboard-and-layout-wave.md) | PR #395 | +| 2026-06-05 | Command palette worktree/folder selection focuses Canvas cards instead of switching to Normal — see [003](003-keyboard-and-layout-wave.md) | PR #396 (also touches [031](../031-command-palette-architecture/000-plan.md)) | +| 2026-06-06 | Animate the terminal subtree's pinned size during card resize/refit so content tracks the title bar; drag-resize stays immediate — see [003](003-keyboard-and-layout-wave.md) | PR #400 | +| 2026-06-06 | Default card size scales with host screen width (800×550 → 1000×680, clamped linear interpolation) — see [003](003-keyboard-and-layout-wave.md) | PR #401 | +| 2026-06-06 | Expand a card in place (magic-move) with frozen background, scrim, gesture lock, `⌘⌥E`, and palette entries via `CanvasCommandRequest` — see [003](003-keyboard-and-layout-wave.md) | PR #402 | +| 2026-06-16 | Shared terminal tab context menu on Canvas card title bars (Rename Tab, Change Tab Icon, close variants) with Canvas-local icon picker sheet (fork issue #453) — see [004](004-completeness-wave.md) | PR #457 | +| 2026-06-25 | Canvas `?` help popover reveals on hover (150 ms grace, click-to-pin), matching the notifications bell; extracted `CanvasHelpButton` — see [004](004-completeness-wave.md) | PR #507 | +| 2026-06-25 | Normal toolbar status item added to Canvas; Open on Code Host / Open Pull Request + palette PR entries route through the focused card — see [004](004-completeness-wave.md) | PR #509 | +| 2026-06-27 | Spatial card navigation with `⌘⌃`-arrows (`CanvasSpatialNavigation`, weighted 2D distance); the reducer redirects Default-view worktree selection into Canvas navigate commands while Canvas is active — see [004](004-completeness-wave.md) | PR #514 | + +## Outcome & current state (as of 2026-07-12) + +All verified in the working tree: + +- **Pointer navigation** — `supacode/Features/Canvas/Views/CanvasSupportViews.swift`: + `CanvasScrollCoordinator` (zoom entry point), `CanvasZoomMath`, `CanvasViewportMath`, + `CanvasViewportAnimator`, and `CanvasScrollContainerView` whose local monitor matches + `[.otherMouseDown, .otherMouseDragged, .otherMouseUp]` with `buttonNumber == 2`. + The #229 revert holds: `hasScrollbackContent` no longer exists anywhere in + `supacode/`. +- **Cards** — `supacode/Features/Canvas/Views/CanvasCardView.swift`: hover + expand/restore + close (`xmark`) buttons with shortcut-bearing tooltips, and the + shared `TerminalTabContextMenuActions` menu (#457). `AnimatedExpandableCard` and + `CardScreenGeometry` (both in `CanvasSupportViews.swift`) plus + `supacode/Features/Canvas/Models/CanvasExpandGeometry.swift` implement + expand-in-place; the toolbar scrim flag `forceMaterialScrim` lives in + `supacode/Domain/WindowChromeTint.swift` and is applied by + `supacode/Features/Repositories/Views/WorktreeDetailView.swift`. +- **Layout & persistence** — + `supacode/Features/Canvas/Models/CanvasCardLayout.swift`: `CanvasCardLayout` with + `minDefaultSize`/`maxDefaultSize` and `adaptiveDefaultSize(forScreenWidth:)` (#401), + `CanvasCardPacker`, and `CanvasLayoutStore` (#329). `CanvasTileLayout` in the same + file is [043](../043-canvas-tile-layout/000-plan.md)'s work. +- **Focus & command routing** — + `supacode/Features/Canvas/Models/CanvasFocusRequest.swift`: `CanvasFocusRequest`, + `CanvasFocusCandidate`, `CanvasCommandRequest`, `CanvasFocusResolver`; request/consume + actions (`focusCanvasWorktree`, `requestCanvasCommand`, + `consumeCanvasCommandRequest`) in + `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`; consumption in + `supacode/Features/Canvas/Views/CanvasView+Focus.swift` / `CanvasView.swift`. +- **Action-target fallback** — + `supacode/Features/App/Reducer/AppFeature+Support.swift`: `actionTargetWorktree` + falls back to `canvasFocusedTerminalWorktree` (via + `terminalClient.canvasFocusedWorktreeID()` when `isShowingCanvas`). +- **Keyboard** — `supacode/App/AppShortcuts.swift`: `arrangeCanvasCards` = `⌘⌥R`, + `organizeCanvasCards` = `⌘⌥G`, `expandCanvasCard` = `⌘⌥E`. + `supacode/Features/Canvas/Views/CanvasView.swift` mounts `.onKeyPress` handlers for + escape (broadcast clear), select-all, arrange, organize, tile + ([043](../043-canvas-tile-layout/000-plan.md)), and expand. `⌘⌃`-arrow navigation is + *not* view-local: `RepositoriesFeature+CoreReducer.swift` redirects the worktree + selection actions to `.requestCanvasCommand(.navigate(...))` when `isShowingCanvas`, + fulfilled in `supacode/Features/Canvas/Views/CanvasView+Focus.swift` via + `navigateCard(_:)`; `supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift` + holds the pure nearest-card logic. +- **Boot view** — `supacode/Features/Settings/Models/DefaultViewMode.swift` has + `case canvas` (#337). +- **Help & toolbar** — `supacode/Features/Canvas/Views/CanvasHelpButton.swift` (#507); + `WorktreeDetailView.swift` builds `canvasToolbarState`/`canvasToolbarContent` + including the status item (#509) and the custom-action cluster (#362). +- **Tests** — `supacodeTests/`: `CanvasZoomMathTests`, `CanvasLayoutStoreTests`, + `CanvasCardPackerTests`, `CanvasCardSizingTests`, `CanvasExpandGeometryTests`, + `CanvasFocusResolverTests`, `CanvasSpatialNavigationTests`. +- **User docs** — `docs/components/canvas.md`, `docs/components/view-modes.md`. + +## Deviations from plan + +- #226's expand button was an acknowledged stopgap (exit Canvas to the tab view); it + was replaced by the expand-in-place design in #402, so the original behavior no + longer exists. +- The fixed default card size assumed by v1 and the early waves was retired by #401; + `CanvasCardLayout.defaultSize` survives only as a transient fallback (kept at the max + size). +- #514's PR description says arrow navigation is handled by four `.onKeyPress` handlers + in `CanvasView`; a fix commit inside the same PR (`29de4e5f`) reworked it into the + reducer→command channel described above, so the merged behavior matches the + `CanvasCommandRequest` mechanism, not the PR body. + +## Open questions + +- PR #396 is listed as material for both this entry and + [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md); + it is logged here (Canvas-side routing) and should appear in 031 only as a + cross-reference. diff --git a/docs-ai/024-canvas-interaction-evolution/002-first-class-canvas.md b/docs-ai/024-canvas-interaction-evolution/002-first-class-canvas.md new file mode 100644 index 00000000..dccd567b --- /dev/null +++ b/docs-ai/024-canvas-interaction-evolution/002-first-class-canvas.md @@ -0,0 +1,52 @@ +# 024.002 — Canvas as a First-Class View (#329, #337, #362) + +## Context + +By late May 2026, Canvas navigation felt good but the mode was still a guest in its own +app: card positions reset on every launch (an auto-arrange ran on first Canvas entry +and clobbered whatever the user had built), the Default View setting only offered +Normal and Shelf, and the toolbar's Run Script / Custom Commands cluster +([002-custom-commands](../002-custom-commands/000-plan.md)) was inert in Canvas — using +a custom action meant leaving the view. + +## Change + +- **#329 — persist card layout order** (fixes fork issue #328, 2026-05-24). Saved card + positions are preserved on first Canvas entry after launch instead of being + auto-arranged over; card z-order persists and focused cards are brought to front. The + previous `UserDefaults` layout dictionary format is migrated. `CanvasLayoutStore` + (in `supacode/Features/Canvas/Models/CanvasCardLayout.swift`) owns the persistence, + with dedicated `CanvasLayoutStoreTests`. +- **#337 — Canvas in Default View** (2026-05-25). `DefaultViewMode` gains `.canvas`; + the Appearance settings picker and `Codable` persistence pick it up via + `CaseIterable`/`Codable`. On non-Layout-Restore launches `RepositoriesFeature` + dispatches `.toggleCanvas` after the initial repository snapshot; on Layout-Restore + launches `AppFeature` enters Canvas on `.layoutRestored` *after* selection effects so + `.selectCanvas` records the just-selected worktree as the pre-Canvas anchor. With no + worktree rows it falls back to Normal, mirroring Shelf's guard. +- **#362 — Canvas custom actions + toolbar refinements** (2026-05-28). Carries + community PR #358 by vince-hz (fixes fork issue #357): Run Script / Stop Script / + Custom Commands route through the focused Canvas card, per-repo settings sync as + focus moves between cards, and the actions surface in toolbar/menu/palette. Fork + refinements on top: the toolbar honors `showRunButtonInToolbar`; repository + user + settings apply in a single reduce pass on `canvasFocusedWorktreeChanged` (shared + `applyWorktreeSettings` helpers, reused by the normal selection path); and the Run + + Custom Command cluster renders as a single `ToolbarItem { HStack }` to stop NSToolbar + from animating per-item insert/remove when switching between cards with different + command counts (documented in-code as an intentional divergence from the Normal + toolbar). + +## Refs + +- PR #329, PR #337, PR #362 (supersedes community #358) +- Custom command semantics: [002-custom-commands](../002-custom-commands/001-action.md) + +## Current state + +- `CanvasLayoutStore` and its tests exist as described. +- `supacode/Features/Settings/Models/DefaultViewMode.swift` has `case canvas`. +- The Canvas toolbar is built by `canvasToolbarState`/`canvasToolbarContent` in + `supacode/Features/Repositories/Views/WorktreeDetailView.swift`; focused-card action + routing now goes through the generalized `actionTargetWorktree` fallback in + `supacode/Features/App/Reducer/AppFeature+Support.swift` (see + [001-action.md](001-action.md)). diff --git a/docs-ai/024-canvas-interaction-evolution/003-keyboard-and-layout-wave.md b/docs-ai/024-canvas-interaction-evolution/003-keyboard-and-layout-wave.md new file mode 100644 index 00000000..b585021c --- /dev/null +++ b/docs-ai/024-canvas-interaction-evolution/003-keyboard-and-layout-wave.md @@ -0,0 +1,74 @@ +# 024.003 — Keyboard & Layout Wave (#393–#396, #400–#402) + +## Context + +A concentrated two-day burst (2026-06-05 → 06-06) attacked the remaining "leaves Canvas +when it shouldn't" paths and the visual quality of card layout. Multi-agent keyboard +flow was interrupted by mouse-only layout buttons (fork issue #392); New Tab created +tabs in the *Normal-selected* worktree rather than the focused card; clicking sidebar +rows, Active Agents entries, or palette results silently exited Canvas; Organize/Arrange +animated the card chrome but snapped the terminal content; the fixed default card size +made text unreadably small on 14" screens after fit-to-view; and "expand" still meant +leaving Canvas. + +## Change + +- **#393 — Arrange/Organize shortcuts.** `⌘⌥R` (Rearrange) and `⌘⌥G` (Grid) join the + Canvas `⌘⌥` family (`Cmd+Shift+R` was taken by `refreshWorktrees`). Registered in + `supacode/App/AppShortcuts.swift` with `.localInteraction` scope, so they enter the + keybinding schema user-rebindable; grouped with Canvas actions in + `ShortcutsSettingsView`. Handled by `.onKeyPress` inside `CanvasView.body` — mounted + only while Canvas is visible — with shared `arrangeCardsWithFit()` / + `organizeCardsWithFit()` helpers used by both buttons and keys; button tooltips show + the shortcut via `AppShortcuts.helpText`. +- **#394 — New tab target fix.** New Terminal/New Tab route through the active + terminal target so Canvas creates tabs in the focused card's worktree; Ghostty + terminal commands appear in the command palette when Canvas has a focused action + target. +- **#395 — Sidebar navigation stays in Canvas.** Clicking sidebar repo/worktree/folder + rows or Active Agents entries no longer exits Canvas: they emit Canvas focus requests + that create a missing card when needed, select/cycle matching cards, and + center/scale the focused card. Introduces `CanvasFocusRequest`/`CanvasFocusCandidate` + and the pure `CanvasFocusResolver` + (`supacode/Features/Canvas/Models/CanvasFocusRequest.swift`). +- **#396 — Palette card focus.** Command palette worktree/plain-folder selection routes + to `focusCanvasWorktree` / `focusCanvasRepository` instead of Normal selection while + Canvas is active (palette internals belong to + [031](../031-command-palette-architecture/000-plan.md)). +- **#400 — Resize animation.** The Canvas terminal subtree's pinned size animates + during card resize transitions and non-interactive refits (Organize/Arrange), keeping + terminal content in sync with the title bar; the explicit size animation is disabled + while a drag-resize gesture is active so interactive resize stays immediate. +- **#401 — Adaptive default card size.** `CanvasCardLayout.adaptiveDefaultSize(forScreenWidth:)` + interpolates 800×550 (≤1512 pt) → 1000×680 (≥2560 pt), clamped; wired into + `ensureLayouts` (new cards) and `organizeCards` (uniform grid). Smaller cards on + small screens raise the fit-to-view scale so text renders larger. Covered by + `CanvasCardSizingTests`. +- **#402 — Expand a card in place (magic-move).** Replaces the expand-to-tab-view + stopgap from #226. The focused card raises to the top and animates alone from its + in-canvas frame to scale 1 covering the viewport; the canvas transform is never + mutated, so the background stays frozen behind a dimmed/blurred scrim while other + cards keep running. Restore via button toggle, scrim tap, or title-bar double-click; + Arrange/Organize cancel the expansion; pan/pinch/scroll/middle-drag are locked while + expanded. Driven by `AnimatedExpandableCard` (`Animatable`, `animatableData = + progress`) so size/center/scale interpolate per frame and the terminal reflows in + lock-step. Adds `⌘⌥E`, Canvas-only palette entries (Expand/Restore, Arrange, + Organize, Select All) routed via the one-shot `CanvasCommandRequest` + (reducer-requested, `CanvasView`-consumed), the `forceMaterialScrim` toolbar flag, + and `CanvasExpandGeometry` (+ tests). + +## Refs + +- PRs #393, #394, #395, #396, #400, #401, #402 (all merged 2026-06-05/06) +- Fork issue #392 (layout shortcuts) + +## Current state + +All named types verified in the tree: `AppShortcuts.arrangeCanvasCards` / +`organizeCanvasCards` / `expandCanvasCard`; `CanvasFocusRequest.swift` (including +`CanvasCommandRequest` and `CanvasFocusResolver`); `CanvasExpandGeometry.swift`; +`AnimatedExpandableCard` in `CanvasSupportViews.swift`; `adaptiveDefaultSize` in +`CanvasCardLayout.swift`; `arrangeCardsWithFit()` / `organizeCardsWithFit()` and the +shortcut `.onKeyPress` handlers in `CanvasView.swift` (the set has since grown a tile +handler from [043](../043-canvas-tile-layout/000-plan.md)). Tests: +`CanvasCardSizingTests`, `CanvasExpandGeometryTests`, `CanvasFocusResolverTests`. diff --git a/docs-ai/024-canvas-interaction-evolution/004-completeness-wave.md b/docs-ai/024-canvas-interaction-evolution/004-completeness-wave.md new file mode 100644 index 00000000..453d4b27 --- /dev/null +++ b/docs-ai/024-canvas-interaction-evolution/004-completeness-wave.md @@ -0,0 +1,56 @@ +# 024.004 — Completeness Wave (#457, #507, #509, #514) + +## Context + +After the June keyboard/layout wave, the remaining gaps were parity leftovers rather +than structural problems: tab management actions (rename, icon, close variants) existed +only in the Normal tab bar; the Canvas `?` help required a click while the visually +identical notifications bell opened on hover; the Canvas toolbar lacked the center +status item and the code-host/PR actions targeted the Normal-selected worktree; and +`⌘⌃`-arrows — worktree navigation in Default view — forced an unwanted view-mode switch +when pressed in Canvas. + +## Change + +- **#457 — Card tab context menu** (fixes fork issue #453, 2026-06-16). The shared + terminal tab context menu (`TerminalTabContextMenuActions`) attaches to Canvas card + title bars, wired to the owning worktree's tab state: Rename Tab, Change Tab Icon, + and the tab close variants. A Canvas-local icon picker sheet keeps the reused menu + functional outside Default/Shelf views. +- **#507 — Help on hover** (2026-06-25). The bottom-left help affordance was extracted + into `CanvasHelpButton` (`supacode/Features/Canvas/Views/CanvasHelpButton.swift`) and + reuses the notifications-bell interaction: hover opens the popover, leaving dismisses + after a 150 ms grace period, click pins it open. `docs/components/canvas.md` was + updated in the same PR. +- **#509 — Toolbar code-host actions** (2026-06-25). The Normal toolbar's center status + item (PR/check status, toasts, time hint) now renders on Canvas for the focused card, + and Open on Code Host / Open Pull Request plus the palette's PR entries route through + the Canvas-focused card while Canvas is active. PR-state semantics belong to + [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md). +- **#514 — Spatial card navigation** (2026-06-27). `⌘⌃`-arrows navigate between cards + by 2D center coordinates instead of falling through to Default view's linear worktree + selection (which forced an unwanted view-mode switch). `CanvasSpatialNavigation` + (`supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift`) is a pure-logic + nearest-card finder using weighted distance (primary axis + 2× cross axis) to prefer + aligned neighbors over diagonal ones. As merged, the arrows are not view-local key + handlers: when `isShowingCanvas`, the worktree-selection actions in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` + redirect to `.requestCanvasCommand(.navigate(...))`, fulfilled by + `CanvasView+Focus.swift` calling `navigateCard(_:)` with viewport follow (an earlier + in-PR iteration used four `.onKeyPress` handlers; fix commit `29de4e5f` moved it to + the reducer→command channel). 18-case `CanvasSpatialNavigationTests` cover grid, + strip, waterfall, and edge cases. + +## Refs + +- PRs #457, #507, #509, #514 +- Fork issue #453 (context menu) + +## Current state + +All four changes verified present: `tabContextMenuActions` in +`supacode/Features/Canvas/Views/CanvasCardView.swift`, `CanvasHelpButton.swift`, +`canvasToolbarState`/`canvasToolbarContent` in +`supacode/Features/Repositories/Views/WorktreeDetailView.swift`, +`CanvasSpatialNavigation.swift` + `CanvasSpatialNavigationTests.swift`, and the +`isShowingCanvas` selection guard in `RepositoriesFeature+CoreReducer.swift`. diff --git a/docs-ai/025-repo-identity-appearance/000-plan.md b/docs-ai/025-repo-identity-appearance/000-plan.md new file mode 100644 index 00000000..793fcbc7 --- /dev/null +++ b/docs-ai/025-repo-identity-appearance/000-plan.md @@ -0,0 +1,102 @@ +# 025 — Repo Identity & Appearance: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-04-27 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #240, #243 (anchor); #247, #276 (follow-ups) | +| **Sources** | PR descriptions #240/#243/#247/#276; upstream review ledger decisions 2026-05-08 and 2026-06-09 (→ `docs-ai/017-upstream-sync-process/upstream-ledger.md`) | +| **Related** | [022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md) (per-*tab* identity), [023-shelf-mode](../023-shelf-mode/000-plan.md) (spine tint preference), [026-sidebar-container-refactor](../026-sidebar-container-refactor/000-plan.md), [033-ui-refresh-2026-05](../033-ui-refresh-2026-05/000-plan.md) (custom color, chrome tint), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +With many repositories open at once, the sidebar, the shelf spine, and the canvas card +title bar all rendered every repo the same way: a plain folder-derived name. Repos were +hard to tell apart at a glance, and repos sharing a generic folder name (several `src` +checkouts) were literally indistinguishable. The idea: a one-time, per-repo visual +identity — icon, color, and optionally a display title — that pays off across every +surface where the repo shows up. + +## Goals + +- Per-repo **icon** (curated SF Symbol presets + free-form symbol name, or a + user-provided PNG/SVG) and **color** (fixed palette of system colors: Finder's 7 plus + mint/cyan/pink), independently optional; repos without an entry render exactly as + before (#240). +- Surface the identity in all three render sites: sidebar row (icon before name, + Finder-style trailing color dot), shelf spine (proximity tint switches from + `accentColor` to the repo color, plus a header icon), canvas card title bar (icon + + always-on color strip) (#240). +- Small papercuts: open the icon image picker directly at the repo's working directory + (#243); let the user override the displayed repo title (#247); animate the sidebar + color dot on hover instead of snapping (#276). + +**Non-goals** + +- Per-*worktree* title or color. Identity is deliberately repo-level; per-worktree + visual distinction stays at the tab layer (custom tab titles/icons, + [022](../022-tab-title-and-icon/000-plan.md)). This scoping later became the anchor + for a standing divergence from upstream — see + [002-upstream-divergence.md](002-upstream-divergence.md). + +## Design / Approach + +As designed in #240: + +- **Data model**: `RepositoryAppearance` (optional `RepositoryIconSource` + optional + `RepositoryColorChoice`), held in a single global + `@Shared([Repository.ID: RepositoryAppearance])` dictionary persisted at + `~/.prowl/repository-appearances.json`. Sidebar, shelf, and canvas read it directly + via `@Shared` — no per-call client layer. One global file rather than per-repo + settings so the sidebar (which renders every row) gets every appearance in a single + read. +- **Icon storage**: a single storage string with marker prefixes (`@asset:` for bundled + assets, `@file:` for user images, bare string = SF Symbol), mirroring the existing + `TabIconSource` convention. User-imported PNG/SVG files live in the per-repo settings + directory (`~/.prowl/repo/<name>/icons/<uuid>.<ext>`), persisted as bare filenames so + the JSON stays portable and the files are cleaned up together with the rest of the + per-repo settings directory on repo removal. +- **Rendering**: `RepositoryIconImage` is the single rendering site so tinting rules + (SF Symbols/SVGs tint with the repo color; PNGs keep their own colors) and fallback + behavior stay consistent across the three surfaces. +- **Mutation**: everything goes through explicit `RepositorySettingsFeature` reducer + actions (`setAppearanceColor`, `setAppearanceIcon`, `importUserImage`, + `resetAppearance`, ...) — no direct `store.appearance.* = ...` writes in views, + keeping the custom SwiftLint rule clean. +- **Picker at repo dir** (#243): switch from SwiftUI `.fileImporter()` (no + initial-directory API) to `NSOpenPanel` with `directoryURL = store.rootURL`, using + `panel.begin` (non-blocking) rather than `runModal`. +- **Custom title** (#247): an optional `customTitle` field on the per-repo + `RepositorySettings` file, surfaced as a "Display Name" section in Repo Settings. + Whitespace-only input normalizes to `nil` so the folder-derived `Repository.name` + remains the fallback. +- **Hover polish** (#276): wrap the sidebar header `isHovering` toggle in + `withAnimation(.easeOut(duration: 0.15))` so the color dot slides when the row's + hover buttons appear; skip the animation under `accessibilityReduceMotion`. + +## Alternatives & decisions + +- **One global appearance dictionary, not nested in `Repository` or per-repo settings** + (#240): all three surfaces need O(1) cross-repo lookups during render; per-repo files + would force one file load per sidebar row at startup. +- **Canvas tint above the `.bar` material, not below** (#240 mid-flight decision): the + bar's 0.9 opacity would dilute a base-layer tint to invisibility. Existing + notification/selected-unfocused tints stay in their original position so repos + without an appearance look unchanged. +- **Fixed system colors only** (#240): named presets stay semantic system colors so + they adapt to light/dark mode. Later relaxed with an explicit `.custom(TintColor)` + opt-out (#332, [033](../033-ui-refresh-2026-05/000-plan.md)); preset persistence kept + the legacy bare-string encoding. +- **`customTitle` lives in `RepositorySettings`, not in the appearance dictionary** + (#247): the title is per-repo configuration alongside scripts and base-ref defaults, + while icon/color stay in the render-hot global dict. +- **Repo-level model kept against upstream's per-repo and later per-worktree + title/color** — the standing divergence decision, recorded in + [002-upstream-divergence.md](002-upstream-divergence.md). + +## Amendments + +- Updated 2026-06-09: upstream per-repo (upstream #276) and per-worktree (upstream + #308/#367) title/color reviewed and skipped; fork keeps its richer repo-level + appearance model — see [002-upstream-divergence.md](002-upstream-divergence.md) diff --git a/docs-ai/025-repo-identity-appearance/001-action.md b/docs-ai/025-repo-identity-appearance/001-action.md new file mode 100644 index 00000000..901690d6 --- /dev/null +++ b/docs-ai/025-repo-identity-appearance/001-action.md @@ -0,0 +1,90 @@ +# 025 — Repo Identity & Appearance: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-27 | Per-repo icon + color identity across sidebar / shelf spine / canvas card: `RepositoryAppearance` model, global `@Shared` dict persisted to `~/.prowl/repository-appearances.json`, `RepositoryIconImage` render site, picker UI in Repo Settings, 30+ tests. In-PR fixes: seed appearance synchronously into `RepositorySettingsFeature.State` (fixes a race where clicking a color before the async load wiped the saved icon) and curate the SF Symbol preset list | PR #240 | +| 2026-04-27 | "Choose Image..." opens at the repo's working directory via `NSOpenPanel` with `directoryURL = store.rootURL` (replacing `.fileImporter()`) | PR #243 | +| 2026-04-29 | Optional `customTitle` on `RepositorySettings` ("Display Name" in Repo Settings); whitespace-only normalizes to `nil`; surfaced across sidebar, shelf, canvas, toolbar/window title, and the Settings repo list. Mid-PR, the display mechanism switched from a per-row leaf-view `@Shared` subscription to a reducer-held title cache | PR #247 | +| 2026-05-11 | Sidebar color dot glides on hover (`withAnimation(.easeOut(duration: 0.15))`) instead of snapping when the hover buttons appear; animation skipped under Reduce Motion | PR #276 | +| 2026-05-08 / 2026-06-09 | Upstream's per-repo, then per-worktree title/color reviewed and skipped — divergence decision | [002-upstream-divergence.md](002-upstream-divergence.md) | + +## Outcome & current state (as of 2026-07-12) + +Domain and persistence: + +- `supacode/Domain/RepositoryAppearance.swift` — optional `icon` + `color`, `.empty` + baseline. +- `supacode/Domain/RepositoryIconSource.swift` — `.sfSymbol` / `.bundledAsset` + (`@asset:`) / `.userImage` (`@file:`), storage-string convention shared with + `TabIconSource`. +- `supacode/Domain/RepositoryColorChoice.swift` — 10 named presets **plus a + `.custom(TintColor)` case added later** (#332, + [033](../033-ui-refresh-2026-05/000-plan.md)); presets still encode as legacy bare + strings so pre-existing user JSON decodes unchanged. +- `supacode/Domain/RepositoryIconPresets.swift` — curated SF Symbol presets (currently + 40 entries; the #240 PR body's "32" predates the in-PR curation commit). +- `supacode/Clients/Repositories/RepositoryAppearancesKey.swift` — `SharedKey` behind + `@Shared(.repositoryAppearances)`, file at `SupacodePaths.repositoryAppearancesURL` + (`~/.prowl/repository-appearances.json`). +- `supacode/Clients/Repositories/RepositoryIconAssetStore.swift` — imports/removes user + images under `SupacodePaths.repositoryIconsDirectory(for:)` + (`~/.prowl/repo/<name>/icons/`), bare filenames in JSON. + +Feature and render sites: + +- `supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift` — + `setAppearanceColor` / `setAppearanceIcon` / `importUserImage` / `resetAppearance` + actions and `customTitle` normalization. +- `supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift` — + picker UI; the #243 `NSOpenPanel` + `directoryURL` behavior is still in place. +- `supacode/Features/Repositories/Views/RepositoryIconImage.swift` — the single icon + render site; used by `RepoHeaderRow.swift`, `ShelfSpineView.swift`, + `CanvasCardView.swift`, and the appearance picker. +- `supacode/Features/Repositories/Views/RepositorySectionView.swift` — sidebar header: + appearance lookup, trailing color dot, and the #276 hover animation with the + `accessibilityReduceMotion` opt-out. +- `supacode/Features/Shelf/Views/ShelfSpineView.swift` and + `supacode/Features/Canvas/Views/CanvasView+Focus.swift` / + `CanvasCardView.swift` — shelf and canvas render sites. + +Custom title plumbing (current shape): + +- `supacode/Features/Settings/Models/RepositorySettings.swift` — `customTitle: String?` + (`decodeIfPresent`, schema-compatible). +- `RepositoriesFeature.State.repositoryCustomTitles` (`supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`) + is the display cache; `RepositoriesFeature+CoreReducer.swift` handles + `refreshCustomTitle` / `customTitlesLoaded` / `customTitleUpdated`, and `AppFeature` + refreshes the cache on every repo-settings change. Read sites include the sidebar + (`RepositorySectionView` → `RepoHeaderRow`), `supacode/App/WindowTitle.swift`, the + Settings repo list, and workspace naming + (`RepositoriesFeature+WorkspaceCreation.swift`). + +Since the anchor, the repo color grew extra consumers outside this entry's scope: +window-chrome tint (`windowTintMode = repositoryColor`, +[033](../033-ui-refresh-2026-05/000-plan.md)) and the shelf spine tint preference +(`shelfSpineTintFollowsRepositoryColor`, #356, +[023](../023-shelf-mode/000-plan.md)). Workspaces +([042](../042-project-workspaces/000-plan.md)) default to a `folder` icon when no +appearance is set. User-facing behavior is documented in +`docs/components/repositories-and-worktrees.md` ("Repository appearance (icon & color)"). + +## Deviations from plan + +- **#247's leaf-view design was superseded inside the same PR.** The PR body describes + a `RepoHeaderTitleTextResolved` leaf view subscribing to + `@Shared(.repositorySettings(rootURL))` per row; the PR's final commits replaced this + with the reducer-held `repositoryCustomTitles` cache, and the leaf view no longer + exists. Displayed titles now flow through `RepositoriesFeature` state. +- **Color palette is no longer "10 fixed colors only"** — `.custom(TintColor)` was + added in #332 (owned by [033](../033-ui-refresh-2026-05/000-plan.md)); noted here + because it changed this entry's domain type. +- **Preset count**: 40 curated presets in the tree vs "32" in the #240 body (curation + happened in an in-PR fix commit, `d8a85b60`). + +## Open questions + +- PR #240 references a local-only decision record (`.agents/repo-icon-color-decisions.md`) + that was never committed; apart from the canvas tint-layer decision summarized in the + body, its remaining mid-flight decisions are unrecoverable. diff --git a/docs-ai/025-repo-identity-appearance/002-upstream-divergence.md b/docs-ai/025-repo-identity-appearance/002-upstream-divergence.md new file mode 100644 index 00000000..e919bd66 --- /dev/null +++ b/docs-ai/025-repo-identity-appearance/002-upstream-divergence.md @@ -0,0 +1,45 @@ +# 025 — Amendment: Upstream Title/Color Divergence + +## Context + +The fork and upstream built overlapping features almost simultaneously: upstream added +per-repository title/color (upstream #276, `9bae228e`, committed 2026-04-25) two days +before the fork merged its repo-level identity model (#240, 2026-04-27). Upstream later +moved identity down a level entirely: per-*worktree* title + color (upstream #308 +`4d07b0a5`, 2026-05-29; upstream #367 `563e6913`, 2026-05-30), reviewed in the fork's +v0.9.0 → v0.10.2 upstream batch. Both tracks overlap what this entry built, so each +upstream review had to decide whether to port, merge, or ignore. + +## Change + +No code change — a standing decision, recorded in the upstream review ledger +(→ `docs-ai/017-upstream-sync-process/upstream-ledger.md`): + +- **2026-05-08 review** (post-v0.8.5 batch): upstream #276 skipped — "Repository + title/color conflicts with Prowl's richer repository appearance model." The fork's + model already covered title (per-repo `customTitle`), color (system palette), and + icons (SF Symbol presets / free-form / user PNG-SVG) across three render surfaces, + backed by one global `@Shared` dictionary; upstream's feature was a subset with a + different persistence shape. +- **2026-06-09 review** (post-v0.10.2 batch): upstream #308/#367 per-worktree + title+color skipped — "fork uses its richer repo-level appearance model, consistent + with the earlier #276 decision." + +The consequence is a deliberate model divergence, not a gap to close later: in the +fork, visual identity attaches to the *repository*; per-worktree distinction is served +at the tab layer (custom tab titles/icons, +[022-tab-title-and-icon](../022-tab-title-and-icon/000-plan.md)). Future upstream +appearance work touching these areas should be evaluated against this baseline rather +than ported mechanically. + +## Refs + +- upstream #276 (`9bae228e`), upstream #308 (`4d07b0a5`), upstream #367 (`563e6913`) +- Ledger entries 2026-05-08 and 2026-06-09 — + `docs-ai/017-upstream-sync-process/upstream-ledger.md` + +## Current state + +As of 2026-07-12 the fork has no per-worktree title or color override (no +`WorktreeAppearance`-like type exists in the tree); repo-level appearance remains the +only repo identity mechanism, and per-tab identity is handled by entry 022. diff --git a/docs-ai/026-sidebar-container-refactor/000-plan.md b/docs-ai/026-sidebar-container-refactor/000-plan.md new file mode 100644 index 00000000..ed8ac3ec --- /dev/null +++ b/docs-ai/026-sidebar-container-refactor/000-plan.md @@ -0,0 +1,115 @@ +# 026 — Sidebar Container Refactor: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-03 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #250, #252 (amendment: #254) | +| **Sources** | `doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md` (absorbed here; original removed in the docs-ai migration), fork issues #249 and #222, PR #250/#252/#254 descriptions | +| **Related** | [015-repositories-feature-refactor](../015-repositories-feature-refactor/000-plan.md), [032-performance-hardening](../032-performance-hardening/000-plan.md), [033-ui-refresh-2026-05](../033-ui-refresh-2026-05/000-plan.md), [042-project-workspaces](../042-project-workspaces/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +The repository sidebar was a single SwiftUI `List(selection:)` in which repository +headers and expanded worktree rows were *separate list cells*, while the app's data model +treats each repository as one reorderable unit. That structural mismatch produced three +user-visible bugs: + +1. **Wrong drag insertion indicator**: dragging a repository downward across an expanded + repository drew the indicator *between* the target's header and its worktree rows — + `List` only knows row boundaries, not repository boundaries. +2. **Unstable bulk expand/collapse animations**: collapsing many repositories at once + removed a large set of cells in one transaction and `List` cell reuse made tail items + animate from wrong starting positions. This became prominent when PR #250 added the + sidebar-header expand/collapse-all toggle, and was filed as issue #249. +3. **Drag-time flicker** (issue #222): live terminal notification / task state updates + reached rows mid-drag, and notification-driven "move worktree to top" reordering could + mutate row order with animation while a drag session was active. + +The old `List` also carried a lot of implicit behavior (multi-selection, plain-folder +repository selection, native `onMove` for repositories and pinned/unpinned worktrees, +`ScrollViewReader.scrollTo` reveal, native styling/accessibility) that any replacement +had to preserve or intentionally re-own. + +## Goals + +- Make each repository one stable visual and drag unit: repository containers are the + only repository-level siblings in the outer stack; worktrees are children *inside* + the container. +- Repository drag insertion indicators render only at repository-container boundaries. +- Expand/collapse animates inside the container, so bulk collapse no longer reshapes the + outer list. +- Defer notification-driven worktree reordering while a sidebar drag is active + (fixes #222-class flicker), flushing deterministically on drag end. +- Keep existing reducer ordering actions and persistence paths + (`pinnedWorktreesMoved`, `unpinnedWorktreesMoved`, repository order, `@Shared` + collapsed-repository-ID write-back) unchanged. +- Preserve selection semantics (multi-select, plain folders, Canvas/Shelf/Archived + rows), focused values, context menus, drag previews, root-level URL drop, and + reveal-in-sidebar. + +### Non-goals + +- Cross-repository worktree drag (the model does not support moving worktrees between + repositories). +- Finder-grade keyboard navigation; V1 only preserves the existing command shortcuts + (`selectNextWorktree`, `selectPreviousWorktree`, reveal, numbered hotkeys). +- Moving Canvas, Shelf, and the footer into the scroll content — they stay safe-area + inset chrome around the list. + +## Design / Approach + +The plan (kept as `doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md` at +the time, absorbed here) chose a full custom container over patching `List`: + +- **M1 — reducer-level drag gate** (hard prerequisite): add sidebar drag state to + `RepositoriesFeature`; while a drag is active, `worktreeNotificationReceived` records + pending worktree IDs instead of reordering immediately; drag end flushes pending + reorders in deterministic order, dropping stale IDs, and persists only when a reorder + is actually applied. +- **Pure presentation model**: a `SidebarPresentation` struct built by pure functions + from reducer state, with one `SidebarItem` per repository + (`listHeader` / `repository` / `failedRepository` / `archivedWorktrees`), worktree + sections nested inside `SidebarRepositoryContainerModel`, stable + `SidebarScrollID`s, and pure drop-destination mapping that dispatches the *existing* + ordering actions. High-frequency terminal state stays in leaf views, not in the + presentation model. +- **Container view**: replace `List(selection:)` with `ScrollViewReader` + `ScrollView` + + `LazyVStack` where `RepositoryContainerRow`s are the outer rows. Selection visuals, + click handling, and the `sidebarSelections → setSidebarSelectedWorktreeIDs` sync + become explicit code instead of `List` side effects. +- **Custom drag/drop**: repository rows drag a repository-ID payload with a custom + insertion indicator drawn at container boundaries; worktree reorder stays scoped to + pinned/unpinned sections inside one container, with main/pending rows non-movable. +- Phased execution (baseline metrics → M1 → presentation model + tests → render-only + new path behind a switch → explicit selection/reveal → custom repo reorder → custom + worktree reorder → delete the old `List` path), with a manual verification matrix of + 18 checks and reducer/presentation unit tests. + +## Alternatives & decisions + +- **Option A — keep `List`, nest worktrees inside one repository row**: rejected; + nested selectable rows no longer participate in `List(selection:)`, worktree `onMove` + becomes awkward, and it leaves a hard-to-debug mix of native and custom drag logic. +- **Option B — full `ScrollView` + explicit rows**: chosen; model and visual structure + match, drag/drop and selection become explicit and testable, and `List` cell reuse is + eliminated as a bug class — at the cost of re-owning selection, keyboard, reorder, + and accessibility. +- **Option C — drag-time freeze only**: demoted from alternative to the mandatory M1 + prerequisite; it helps #222 but cannot fix the insertion indicator because row + boundaries stay wrong. +- **Do not fix the indicator via reducer index changes** — it is a symptom of `List` + row structure, not of the persisted ordering logic. +- **`LazyVStack` first, plain `VStack` as fallback**, to be decided by expand/collapse + latency and drag frame-stability metrics rather than visual impression (this fallback + was in fact taken later — see 001-action.md). +- **Failed repository rows are reorderable** and persist through the same root ordering + path (the plan required making this an explicit product decision either way). + +## Amendments + +- Updated 2026-05-05: Add Repository moved from the sidebar footer to the (now + unconditional) "Repositories" header with a zero-repo onboarding hint (#254); both + affordances were later superseded — see + [002-add-repository-entry-point.md](002-add-repository-entry-point.md) diff --git a/docs-ai/026-sidebar-container-refactor/001-action.md b/docs-ai/026-sidebar-container-refactor/001-action.md new file mode 100644 index 00000000..1d19f084 --- /dev/null +++ b/docs-ai/026-sidebar-container-refactor/001-action.md @@ -0,0 +1,97 @@ +# 026 — Sidebar Container Refactor: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-30 | Sidebar "Repositories" header (shown when repo count > 10) with an icon-only expand/collapse-all toggle; collapse preferred when any expandable repo is open; plain folders excluded from the expandable set. Exposed the unstable bulk-collapse animation → issue #249 | PR #250 | +| 2026-05-03 | The container refactor, in one PR: reducer-level drag gate deferring notification reorders, pure `SidebarPresentation` model, `ScrollView`/`LazyVStack` container replacing `List(selection:)`, custom repository/worktree drag & drop with overlay insertion indicators, explicit selection visuals, plain-text internal drag payloads; old `List` path removed in the same change | PR #252 (refs #249, #222) | +| 2026-05-05 | Add Repository moved to the sidebar header + zero-repo onboarding hint; header made unconditional | PR #254 — see [002-add-repository-entry-point.md](002-add-repository-entry-point.md) | + +Later changes that reshaped this surface belong to other entries: the 2026-05-24 UI +refresh reworked the header/empty state +([033-ui-refresh-2026-05](../033-ui-refresh-2026-05/000-plan.md)), #398 replaced +`LazyVStack` with a plain `VStack` +([032-performance-hardening](../032-performance-hardening/000-plan.md)), and workspaces +added child-repository rows to the containers +([042-project-workspaces](../042-project-workspaces/000-plan.md)). + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/Repositories/Models/SidebarPresentation.swift` — the pure model: + `SidebarPresentation.items` with `SidebarItem` + (`listHeader` / `repository` / `failedRepository` / `archivedWorktrees`), + `SidebarPresentationItemID`, `SidebarScrollID`, and + `SidebarRepositoryContainerModel` (worktree sections built only when expanded). The + builder lives in the same file as + `RepositoriesFeature.State.sidebarPresentation(expandedRepositoryIDs:includesArchivedWorktreesRow:)`, + converging empty and custom ordered roots into one path. Drop mapping: + `SidebarWorktreeDropTarget.action` dispatches the pre-existing + `pinnedWorktreesMoved` / `unpinnedWorktreesMoved` ordering actions; + `repositoryOrderAfterMove(fromOffsets:toOffset:)` computes the new root order. + Workspace additions (`isWorkspace`, `workspaceChildRows`) came later via 042. +- `supacode/Features/Repositories/Views/SidebarListView.swift` — the container view: + `ScrollViewReader` + `ScrollView` + `VStack` (a comment explains why not `LazyVStack`; + swapped by #398, see 032), repository list header with the expand/collapse-all button + from #250, explicit selection handling, root-level URL drop, and reveal via + `.task(id: pendingSidebarReveal?.id)` → `revealPendingSidebarWorktree`. +- `supacode/Features/Repositories/Views/SidebarDragSupport.swift` — `SidebarDragProvider` + (plain-text `NSItemProvider` payloads with repo/worktree prefixes, chosen for SwiftUI + drop compatibility), `SidebarRepositoryDropDelegate` / `SidebarWorktreeDropDelegate`, + `SidebarDropIndicator` (drawn as an overlay so it does not affect layout, one + indicator per boundary), and `SidebarDropTargetActions`. +- Drag gate: `RepositoriesFeature.State.isSidebarDragActive` and + `pendingSidebarNotifyReorderIDs` (`supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`); + `setSidebarDragActive` handling and the deferred + `worktreeNotificationReceived` path in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift` — + pending IDs are de-duplicated, flushed in order on drag end, and persistence runs only + when a reorder actually applied. +- `supacode/Features/Repositories/Views/RepositorySectionView.swift` and + `WorktreeRowsView.swift` were *not* deleted (the plan's Phase 6 allowed folding them + in): they survive as the container's header row and child-row stack inside the new + scroll container. `supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift` + joined later (042). +- Expanded/collapsed persistence preserved as required: + `supacode/Features/Repositories/Views/SidebarView.swift` holds + `@Shared(.appStorage("sidebarCollapsedRepositoryIDs"))` and derives the expanded-set + binding that `SidebarListView` consumes. +- Tests: `supacodeTests/SidebarPresentationTests.swift` (one outer item per expanded + repo, failed repos participate in root order, plain folders have no children, + pinned/main/pending/unpinned preserved, empty vs custom roots equivalence, drop + mapping), `supacodeTests/SidebarDragSupportTests.swift`, drag-gate tests in + `supacodeTests/RepositoriesFeatureTests.swift` + (`worktreeNotificationDuringSidebarDragDefersReorder`, + `endingSidebarDragAppliesPendingNotificationReordersInOrder`), and + `supacodeTests/RepositorySectionViewTests.swift` (expand-toggle logic from #250, + e.g. `SidebarListView.expandableRepositoryIDs` / `repositoryListHeaderAction`). + +## Deviations from plan + +- The phased rollout (render-only path behind a switch, then selection, then repo + reorder, then worktree reorder, then delete the `List` path) collapsed into the single + PR #252, which shipped the container path and removed `List(selection:)` directly. +- The plan recommended starting with `LazyVStack`; #252 did, but PR #398 (2026-06-06, + see [032-performance-hardening](../032-performance-hardening/000-plan.md)) replaced it + with a plain `VStack` because SwiftUI's lazy placement cache could spin on the main + thread while scrolling after collapse/expand of large sections — the fallback the + plan's Phase 0 metrics had anticipated. +- The plan required replacing fixed-yield reveal with an event-driven row-availability + signal; `revealPendingSidebarWorktree` still uses two fixed `await Task.yield()` calls + before `scrollTo`. PR #252's notes explicitly left this (and keyboard/accessibility + parity beyond command shortcuts) as follow-up. +- `RepositorySectionView` / `WorktreeRowsView` were repurposed rather than removed. + +## Open questions + +- The fixed two-`Task.yield()` reveal materialization + (`supacode/Features/Repositories/Views/SidebarListView.swift`, + `revealPendingSidebarWorktree`) was never replaced with the event-driven signal the + plan demanded; it works in practice but remains timing-based. +- `SidebarPresentation.showsListHeader(repositoryCount:)` ignores its parameter and + returns `true` unconditionally — a vestige of the #250 ">10 repos" rule that #254 + removed; the actual gating today is `!repositoryItems.isEmpty` in `SidebarListView`. + Harmless, but the function is dead logic. +- The accessibility parity pass (plan's Phase 6 "final accessibility pass") has no + dedicated follow-up PR in this entry's scope; command-shortcut coverage exists, but + full native-`List` accessibility equivalence was never verified in the sources. diff --git a/docs-ai/026-sidebar-container-refactor/002-add-repository-entry-point.md b/docs-ai/026-sidebar-container-refactor/002-add-repository-entry-point.md new file mode 100644 index 00000000..5ab517b3 --- /dev/null +++ b/docs-ai/026-sidebar-container-refactor/002-add-repository-entry-point.md @@ -0,0 +1,44 @@ +# 026 — Amendment: Add Repository Entry Point & Onboarding Hint (#254) + +## Context + +Right after the container refactor landed, the "Repositories" list header (introduced by +#250 only for >10 repositories) was still absent in the common case, leaving the section +unlabeled, and the Add Repository action lived in the sidebar footer where new users did +not look for it. + +## Change + +PR #254 (merged 2026-05-05): + +- Show the "Repositories" header unconditionally (`SidebarPresentation.showsListHeader` + changed to always return `true`). +- Move Add Repository from the sidebar footer to a `+` icon-button next to the + expand/collapse chevron in the header; the footer dropped the action and its + `commandKeyObserver`-based shortcut chip. +- Render a zero-repo onboarding hint row under the header — `arrow.turn.up.right` with a + pulsing symbol effect and "Add your first repository". +- Tighten `EmptyStateView` copy to talk about *adding* a repository, keeping the dynamic + `openRepository` shortcut so keybinding overrides show through. + +## Refs + +- PR #254 (merged 2026-05-05), merge `c21bc209`. +- Superseded by: PR #332 commits `13fc410d` / `18de41f2` (2026-05-22/24, part of the + [033-ui-refresh-2026-05](../033-ui-refresh-2026-05/000-plan.md) wave) moved Add + Repository from the header `+` into a sidebar toolbar item and removed the pulsing + zero-repo hint; PR #520 (2026-06-27, + [042-project-workspaces](../042-project-workspaces/000-plan.md)) turned that toolbar + action into the "Add..." (`folder.badge.plus`) button presenting the `AddToProwlView` + popover (Browse / Clone / Workspace). + +## Current state + +As of 2026-07-12 the #254 affordances themselves are gone, but the header it made +unconditional survives: `supacode/Features/Repositories/Views/SidebarListView.swift` +renders the "Repositories" header (with only the expand/collapse-all button) whenever +the repository list is non-empty; with zero repositories the sidebar stays intentionally +empty and the detail pane's +`supacode/Features/Repositories/Views/EmptyStateView.swift` carries the prompt; adding +happens through the sidebar toolbar's "Add..." button and +`supacode/Features/Repositories/Views/AddToProwlView.swift`. diff --git a/docs-ai/027-split-pane-ux/000-plan.md b/docs-ai/027-split-pane-ux/000-plan.md new file mode 100644 index 00000000..dc7ea6ff --- /dev/null +++ b/docs-ai/027-split-pane-ux/000-plan.md @@ -0,0 +1,89 @@ +# 027 — Split Pane UX: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-04 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #253, #258, #279 (+ #435, see Amendments) | +| **Sources** | PR descriptions #253/#258/#279/#435, fork issues #278/#369, upstream review ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`) | +| **Related** | [012-keybinding-system](../012-keybinding-system/000-plan.md), [023-shelf-mode](../023-shelf-mode/000-plan.md), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md), `docs/components/terminal.md` | + +## Background + +Prowl runs several agent panes side by side inside split layouts (normal tabs, +Shelf, Canvas cards). With more than one pane per tab it was hard to tell at a +glance which split pane had keyboard focus: every surface rendered at full +brightness, and the split divider was a hardcoded `.secondary`-styled line that +user themes could not restyle. Ghostty.app already solves the focus-visibility +problem with `unfocused-split-fill` / `unfocused-split-opacity`, and exposes +`split-divider-color`, so users coming from Ghostty expected their existing +config to carry over. Fork issue #278 explicitly requested divider color/width +customization through the Ghostty config pipeline. + +## Goals + +- Make the focused pane obvious in any multi-split layout by dimming unfocused + panes, in all three hosts (tab view, Shelf open-book, Canvas cards). +- Respect the user's Ghostty theming: source the dim tint and the divider color + from Ghostty runtime config rather than hardcoding values. +- Give users an off switch (`Settings → Appearance → Splits`) and a divider + width control. +- Keep the Ghostty fork patch set minimal — no new patched config keys. + +**Non-goals** + +- Split zoom / focus mode (requested later in issue #369; handled as a separate + wave — see Amendments). + +## Design / Approach + +Three steps, each a PR: + +1. **Dim overlay (#253)** — a translucent tint layered on top of each + unfocused terminal surface in `TerminalSplitTreeView`'s `LeafView`, kept + below the progress/search/drag-handle overlays. Initially a black tint with + scheme-adaptive strength (0.30 dark / 0.12 light). Controlled by a new + `dimUnfocusedSplits` toggle in `GlobalSettings` (default on). Requires + plumbing `focusedSurfaceID` through `TerminalSplitTreeView` / `SubtreeView` + / `LeafView` (and the AX container) from all three call sites: + `WorktreeTerminalTabsView`, `ShelfOpenBookView`, `CanvasView`. Single-pane + (no-split) terminals are never dimmed. +2. **Ghostty config alignment (#258)** — replace the hardcoded tint with values + read from Ghostty runtime config: `unfocused-split-fill` (falling back to + `background`) and `unfocused-split-opacity` (inverted into an overlay + opacity). Route click focus and explicit split focus through a shared + active-surface path, and refresh the overlays in all three hosts when the + Ghostty runtime config reloads. Upstream reference: + upstream #260 (supabitapp/supacode@4d19b068). +3. **Divider color + width (#279)** — read Ghostty's existing + `split-divider-color` via `ghostty_config_get` (fallback + `NSColor.separatorColor`). Ghostty hardcodes the visible divider size, so + width is a fork-only `prowl-split-divider-width = N` directive parsed + directly from the primary Ghostty config file + (`ghostty_config_open_path()`), clamped to 0…32 pt; the invisible hit area + is unchanged. Both values flow through + `WorktreeTerminalManager.splitDividerAppearance()` into + `TerminalSplitTreeView` and a new `dividerVisibleSize` argument on + `SplitView`. + +## Alternatives & decisions + +- **Own implementation vs upstream port**: upstream shipped its own inactive + split dimming (upstream #260) in the same window. The fork kept its own + settings-toggle-based overlay from #253 (the upstream ledger records upstream + #260 as "already covered / intentionally different") but aligned the tint + source with Ghostty config in #258, citing upstream #260 as reference. +- **Divider width mechanism**: adding a real Ghostty config key would mean + another patch on the Ghostty submodule fork. Decided instead to parse a + Prowl-namespaced directive (`prowl-split-divider-width`) from the user's + existing Ghostty config file, keeping the Ghostty patch set minimal (PR #279; + answers issue #278's request to reuse the same pipeline as the dim effect). +- **Settings surface**: only the on/off dim toggle lives in Prowl settings; + colors and width stay Ghostty-config-driven so one theme file styles both + apps. + +## Amendments + +- Updated 2026-06-10: split-zoom UX — per-pane zoom buttons, `⌘⌥⇧F` binding, + palette focus-race fix (#435) — see [002-split-zoom-ux.md](002-split-zoom-ux.md) diff --git a/docs-ai/027-split-pane-ux/001-action.md b/docs-ai/027-split-pane-ux/001-action.md new file mode 100644 index 00000000..e6cfe70b --- /dev/null +++ b/docs-ai/027-split-pane-ux/001-action.md @@ -0,0 +1,64 @@ +# 027 — Split Pane UX: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-04 | Dim unfocused split panes: scheme-adaptive overlay, `dimUnfocusedSplits` toggle (default on), `focusedSurfaceID` wired through tab/Shelf/Canvas hosts, divider softened to `NSColor.separatorColor` | PR #253 | +| 2026-05-08 | Tint sourced from Ghostty config (`unfocused-split-fill` / `unfocused-split-opacity`); shared active-surface path for click and explicit split focus; overlays refresh on Ghostty config reload | PR #258 (upstream ref: upstream #260) | +| 2026-05-12 | Honor `split-divider-color`; fork-only `prowl-split-divider-width` parsed from the primary Ghostty config file, clamped 0…32 pt; `dividerVisibleSize` argument on `SplitView` | PR #279 (closes #278) | +| 2026-06-10 | Split-zoom UX wave: per-pane zoom buttons, `⌘⌥⇧F` → `toggle_split_zoom`, palette focus-race fix | PR #435 — see [002-split-zoom-ux.md](002-split-zoom-ux.md) | + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` — the config surface: + `unfocusedSplitOverlayOpacity()` (reads `unfocused-split-opacity`, default + 0.85, inverted and clamped to 0…1), `unfocusedSplitFill()` (reads + `unfocused-split-fill`, falls back to `background`, warns and returns `nil` + if both are missing), `splitDividerColor()` (reads `split-divider-color`), + and `splitDividerWidth()` backed by the `nonisolated static + parseProwlSplitDividerWidth` parser (skips comments, last assignment wins, + clamps to 0…32 pt). +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — + `unfocusedSplitOverlay()` and `splitDividerAppearance()` expose both tuples + to the view layer, returning inert values when the runtime is absent. +- `supacode/Features/Terminal/Views/TerminalSplitTreeView.swift` — `LeafView` + computes `shouldDim` (`isSplit && !isFocused && dimUnfocusedSplits` setting + `&& fill != nil && opacity > 0`) and applies the fill overlay with a 0.12 s + ease-out animation, hit-testing disabled. The settings toggle is read via + `@Shared(.settingsFile)`. +- `supacode/Features/Terminal/Views/SplitView.swift` — `dividerVisibleSize:` + init argument defaulting to `Self.defaultVisibleSize`, fed from + `splitDivider.width` in `TerminalSplitTreeView`. +- `supacode/Features/Settings/Models/GlobalSettings.swift` — + `dimUnfocusedSplits` (default `true`, decode-tolerant); + `supacode/Features/Settings/Views/AppearanceSettingsView.swift` hosts the + toggle; `supacode/Features/Settings/Reducer/SettingsFeature.swift` round-trips + it. +- All three hosts read both appearance tuples per render: + `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift`, + `supacode/Features/Shelf/Views/ShelfOpenBookView.swift`, + `supacode/Features/Canvas/Views/CanvasView.swift`. +- Tests: `supacodeTests/GhosttyRuntimeSplitDividerWidthTests.swift` and + `supacodeTests/SplitTreeTests.swift` exist. +- User-facing docs: `docs/reference/settings-fields.md` documents + `dimUnfocusedSplits`; `docs/components/terminal.md` and + `docs/reference/keyboard-shortcuts.md` document the split-zoom UX. + +## Deviations from plan + +- The #253 hardcoded scheme-adaptive tint (black at 0.30 dark / 0.12 light) no + longer exists — #258 replaced it with the Ghostty-config-driven fill/opacity + four days later, as recorded in the timeline. +- #253's divider color (`NSColor.separatorColor`) survives only as the + fallback in #279's `split-divider-color` path. + +## Open questions + +- The upstream ledger records upstream #260 (inactive split dimming) under + "Reviewed and skipped", while PR #258 explicitly cites upstream #260 + (supabitapp/supacode@4d19b068) as its reference. The ledger label undersells + that #258 aligned the fork with it; bookkeeping-only inconsistency, no code + impact. diff --git a/docs-ai/027-split-pane-ux/002-split-zoom-ux.md b/docs-ai/027-split-pane-ux/002-split-zoom-ux.md new file mode 100644 index 00000000..5981864f --- /dev/null +++ b/docs-ai/027-split-pane-ux/002-split-zoom-ux.md @@ -0,0 +1,61 @@ +# 027 — Amendment: Split-Zoom UX (PR #435) + +## Context + +Fork issue #369 (user request) reported that pane zoom was effectively +unreachable in Prowl: Ghostty's default zoom binding `⌘⇧↵` is claimed by the +Shelf toggle ([023-shelf-mode](../023-shelf-mode/000-plan.md)), whose unbind +argument also removed Ghostty's own zoom binding, and there was no mouse +affordance either. The same issue also asked for a "focus mode" (hide chrome, +terminal only), which was explicitly scoped out and tracked separately. The +implementation plan was captured in a planning comment on #369. + +## Change + +PR #435 (merged 2026-06-10): + +- **Per-pane zoom UI** — hovering a split pane's top drag handle reveals a + zoom button in that pane's top-right corner; a zoomed pane keeps a persistent + exit-zoom button in the same spot, so it is always visible that the pane is + zoomed and how to exit. Buttons carry tooltips with the resolved shortcut. +- **New keybinding `⌘⌥⇧F` → `toggle_split_zoom`** — registered through + `ghosttyManagedActionBindings` so it works inside terminal panes and is + user-remappable in Settings → Shortcuts (Terminal group; see + [012-keybinding-system](../012-keybinding-system/000-plan.md)). `⌘⌃F` stays + the system fullscreen toggle and `⌘⇧F` remains reserved for a future focus + mode. +- **Palette focus-race fix** (ported from upstream #337) — palette-dispatched + Ghostty binding actions previously ran via an async effect, by which time + AppKit could have moved first responder to another pane. The reducer now + captures the target surface synchronously and routes through a new + surface-targeted `performBindingActionOnSurface` terminal command (see + [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md)). + +Note: the upstream ledger lists upstream #337 ("split-zoom indicator button to +tab bar") under "Not yet ported" — the fork deliberately chose per-pane buttons +over upstream's tab-bar indicator and absorbed only the focus-race fix. + +## Refs + +- PR #435; fork issue #369 (closed by it); upstream #337. + +## Current state (as of 2026-07-12) + +- `supacode/Features/Terminal/Views/TerminalSplitTreeView.swift` — + `SplitZoomButton` view; reveal logic keyed on `isZoomed`, `isHandleHovering`, + and `isZoomButtonHovering` (the latter survives the cursor hand-off from the + drag handle to the button); tooltip resolved via + `AppShortcuts.CommandID.toggleSplitZoom`. +- `supacode/Features/Terminal/Models/SplitTree.swift` — `zoomed: Node?` model + state, cleared/remapped on split mutations; + `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` + handles the `.toggleZoom(surfaceId:)` operation. +- `supacode/App/AppShortcuts.swift` — `CommandID.toggleSplitZoom`, + `AppShortcut(key: "f", modifiers: [.command, .option, .shift])`, and the + managed-binding pair `(CommandID.toggleSplitZoom, "toggle_split_zoom")`. +- `supacode/Clients/Terminal/TerminalClient.swift` — + `Command.performBindingActionOnSurface(Worktree, surfaceID: UUID, action: + String)`, dispatched synchronously from + `supacode/Features/App/Reducer/AppFeature+CommandPalette.swift`. +- Behavior documented in `docs/components/terminal.md` and + `docs/reference/keyboard-shortcuts.md`. diff --git a/docs-ai/028-pr-status-tracking/000-plan.md b/docs-ai/028-pr-status-tracking/000-plan.md new file mode 100644 index 00000000..49a46b47 --- /dev/null +++ b/docs-ai/028-pr-status-tracking/000-plan.md @@ -0,0 +1,101 @@ +# 028 — PR Status Tracking: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-08 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #256, #305, #366, #379 (anchor wave); #425, #456, #469, #519 (merge queue / fork remotes); #496, #499, #500, #501, #505 (status fidelity / cadence); #533, #538, #539 (flicker) | +| **Sources** | PR descriptions listed above; fork issues #452, #462, #463; upstream review ledger (2026-06-09 batch, upstream #352) — see `docs-ai/017-upstream-sync-process/upstream-ledger.md` | +| **Related** | [037-line-diff-tracking](../037-line-diff-tracking/000-plan.md) (sibling sidebar-badge pipeline; per-repo PR observation toggle), [039-gh-cli-hardening](../039-gh-cli-hardening/000-plan.md) (gh subprocess robustness), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md) (#425 port provenance), [033-ui-refresh-2026-05](../033-ui-refresh-2026-05/000-plan.md) (contributor of #521/#532/#533), `docs/components/github-pull-requests.md` | + +## Background + +Prowl inherited GitHub PR integration from upstream supacode: for each worktree branch it +queries the `gh` CLI for the matching pull request and shows state in the sidebar row +(`WorktreeRow`), a toolbar status button, a checks popover, and per-worktree badges, plus +write actions (merge, close, mark ready). The fork's real-world usage put that pipeline +under stress it was not designed for: + +- **Fork clones resolved to the wrong repo.** Repo identity came from parsing the git + remote URL; for fork clones (e.g. `onevcat/Prowl` forked from `supabitapp/supacode`) + this mismatched where the PRs actually live, batch matching produced same-branch false + positives, and write actions (`gh pr merge`/`close`/`ready`) ran without `--repo`, so + they could target the wrong repository (#256, the anchor). +- **The main worktree showed a stale "merged" chip.** Old merged PRs whose head branch + was the default branch got applied to the main worktree row (#305). +- **Refresh cost scaled 2N subprocesses per cycle.** Each of N open repositories spawned + one `gh repo view` (remote resolution) plus one `gh api graphql` (PR fetch) every 30 s. + With 14 repos open that was ~60 subprocesses per minute of steady-state CPU/energy + burn (#366). + +## Goals + +- Resolve the owning GitHub repository correctly for fork clones, and route all PR write + actions through an explicit `--repo`. +- Show only status that is true for the worktree (no stale merged chip on main). +- Collapse the per-repo subprocess storm into roughly one batched GraphQL call per host + per refresh cycle, without letting one bad repo poison the batch. +- Keep the pipeline evolvable: later waves (merge-queue state, multi-remote/fork lookup, + pending-checks fidelity, flicker-free updates) build on the same coordinator. + +**Non-goals** + +- Replacing the `gh` CLI transport with direct token-based API access — auth stays with + `gh auth`; Prowl never handles tokens. +- Non-GitHub code hosts. Unresolved/non-GitHub remotes abort the refresh path. + +## Design / Approach + +As of the anchor wave (reconstructed from #256/#305/#366/#379 PR descriptions): + +- **Repo resolution** (#256): resolve GitHub repository ownership with `gh repo view` + before falling back to git remote parsing; cache the resolved `GithubRemoteInfo` per + repository; pass it to `gh pr merge`/`close`/`ready` via `--repo`; tighten batch PR + matching against fork clones, same-branch false positives, and deleted fork heads. +- **Main-worktree filter** (#305): ignore merged pull requests when applying PR metadata + to the main worktree; keep merged-PR display for feature worktrees (where "Merged" + drives the merged-worktree action flow). +- **Batching architecture** (#366), the load-bearing design of this entry: + - `GithubCLIClient.batchPullRequestsAcrossRepositories` issues one multi-repository + GraphQL query per host, aliasing each repo and its branches (up to 15 repos per + call). Top-level `errors[]` entries are routed back to the owning repository so one + bad permission cannot drop data for the rest. + - A `@MainActor PullRequestRefreshCoordinator` sits between reducer effects and the + client: buckets requests by host, debounces 250 ms to coalesce concurrent enqueues, + serialises per-host work with an inflight lock (mid-flight enqueues queue and flush + after the prior batch), falls back to per-repo `batchPullRequests` concurrently on + partial errors, and applies a 12 s soft timeout that falls back the whole host. + - `resolveGithubRemoteInfo` tries `git remote get-url` (~10 ms) before `gh repo view` + (~200 ms); the batched query is authoritative about repo existence, so the gh call + is redundant in steady state. + - `WorktreeInfoWatcherManager.defaultPullRequestPhaseOffset` returns zero so all repos + co-fire into the same debounce window instead of fragmenting across batches. +- **Fan-out correctness** (#379): group refresh requests by GitHub repo key before + issuing cross-repo batches (multiple local clones of the same GitHub repo previously + trapped `Dictionary(uniqueKeysWithValues:)`), and fan each repo-level result back out + to every local repository that requested it. + +## Alternatives & decisions + +- **Batch GraphQL vs per-repo gh calls**: the 2N-subprocess model was rejected on + measured CPU/energy cost; the coordinator+batch design replaced it (#366). +- **Staged rollout**: #366 shipped behind a `@Shared` app-storage flag during + development and the flag was removed only after end-to-end verification against 14 + live repositories (including a non-GitHub remote exercising the unresolved-remote + abort path). +- **Main-only merged filter** (#305): merged PRs are suppressed only on the main + worktree; feature worktrees intentionally keep showing merged state. +- Later-wave decisions (strict head-repo matching replacing the fork fallback, Set-based + tri-state PR semantics, UNKNOWN-mergeable preservation, expected-checks-as-pending) + are recorded in the amendments below. + +## Amendments + +- Updated 2026-06-27: merge-queue state and fork/multi-remote lookup (#425, #456, #469, + #519) — see [002-merge-queue-and-fork-remotes.md](002-merge-queue-and-fork-remotes.md) +- Updated 2026-07-08: pending-checks fidelity, CLOSED state, selection cooldown, popover + affordances (#496, #499, #500, #501, #505, #521, #532) — see + [003-status-fidelity-and-refresh-cadence.md](003-status-fidelity-and-refresh-cadence.md) +- Updated 2026-07-08: flicker elimination and explicit no-PR semantics (#533 → #538 → + #539) — see [004-flicker-and-no-pr-semantics.md](004-flicker-and-no-pr-semantics.md) diff --git a/docs-ai/028-pr-status-tracking/001-action.md b/docs-ai/028-pr-status-tracking/001-action.md new file mode 100644 index 00000000..3c2e2ab0 --- /dev/null +++ b/docs-ai/028-pr-status-tracking/001-action.md @@ -0,0 +1,118 @@ +# 028 — PR Status Tracking: Action Log + +## Timeline + +Grouped by problem wave; rows chronological within each wave. + +### Wave 1 — Resolution correctness & batching (May) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-08 | Fork-aware repo resolution (`gh repo view` before remote parsing); `--repo` on `gh pr merge`/`close`/`ready`; tightened batch matching for fork clones / same-branch false positives / deleted fork heads | PR #256 | +| 2026-05-19 | Ignore merged PRs when applying metadata to the main worktree; keep merged display for other worktrees | PR #305 | +| 2026-05-29 | `batchPullRequestsAcrossRepositories` (one GraphQL call per host, ≤15 repos aliased); `PullRequestRefreshCoordinator` (250 ms debounce, per-host inflight lock, per-repo fallback, 12 s soft timeout); per-repo `GithubRemoteInfo` cache; `git remote get-url` fast path; zero phase offset so repos co-fire | PR #366 | +| 2026-06-01 | Group refresh requests by GitHub repo key (fixes `Dictionary(uniqueKeysWithValues:)` trap on duplicate clones); fan repo results back to all requesting local repositories; same grouping on the fallback path | PR #379 | + +### Wave 2 — Merge queue & fork remotes (June) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-08 | GitHub merge-queue state: `mergeQueueEntry` in both GraphQL paths, `GithubMergeQueueEntry` + `PullRequestMergeQueueStatus`, brown "Queued" in sidebar/badges, "In merge queue" popover row (port of upstream #352) | PR #425 → [002](002-merge-queue-and-fork-remotes.md) | +| 2026-06-17 | Multi-remote PR lookup: query fork/upstream remote candidates (`origin` > `upstream` > others alphabetically); write actions resolved from the displayed PR's URL (fixes fork issue #452) | PR #456 → [002](002-merge-queue-and-fork-remotes.md) | +| 2026-06-17 | Watch each repo's git config for remote URL changes; debounced refresh of PR state + code-host labels; clear stale badges when no GitHub remote remains (fixes fork issue #463) | PR #469 → [002](002-merge-queue-and-fork-remotes.md) | +| 2026-06-27 | Filter PR matches by head repository; remove the fork fallback that surfaced unrelated same-name branches | PR #519 → [002](002-merge-queue-and-fork-remotes.md) | + +### Wave 3 — Status fidelity & refresh cadence (late June) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-24 | Yellow "N checks running" sidebar state via `PullRequestMergeBlockingReason.checksPending` (fixes fork issue #462) | PR #496 → [003](003-status-fidelity-and-refresh-cadence.md) | +| 2026-06-24 | Fold `EXPECTED` checks into the pending count (follow-up gap in #496) | PR #500 → [003](003-status-fidelity-and-refresh-cadence.md) | +| 2026-06-24 | Cancel the 5 s selection cooldown when switching to a *different* worktree (compare per-repo last selection, not global) | PR #499 → [003](003-status-fidelity-and-refresh-cadence.md) | +| 2026-06-24 | Prune `lastSelectedWorktreeIDByRepo` entries for removed repositories | PR #501 → [003](003-status-fidelity-and-refresh-cadence.md) | +| 2026-06-25 | Track CLOSED PRs: `states: [OPEN, MERGED, CLOSED]` in both queries; badge/summary/popover rendering (orange) | PR #505 → [003](003-status-fidelity-and-refresh-cadence.md) | +| 2026-06-28 | Hover link style (underline, blue, pointer) on CI check names in the checks popover | PR #521 | +| 2026-07-08 | Pointer cursor + unified accessibility hint on the PR title in the checks popover | PR #532 | + +### Wave 4 — Flicker & no-PR semantics (July) + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-05 | Tri-state semantics: `confirmedNoPrBranches: Set<String>` distinguishes "confirmed no PR" from "not queried"; partial fetch failures preserve existing state | PR #533 → [004](004-flicker-and-no-pr-semantics.md) | +| 2026-07-05 | Supersedes #533: fix the nil-literal no-op clear (`updateValue(nil, forKey:)`); order-independent cross-host suppression via `prRefreshFailedBatchRepositoryIDs`; the missing non-empty-set test coverage | PR #538 → [004](004-flicker-and-no-pr-semantics.md) | +| 2026-07-08 | Preserve last-known `mergeable`/`mergeStateStatus` when GitHub returns transient `UNKNOWN` (kills the "Blocked" flash) | PR #539 → [004](004-flicker-and-no-pr-semantics.md) | + +## Outcome & current state (as of 2026-07-12) + +Client layer, `supacode/Clients/Github/`: + +- `GithubCLIClient.swift` — single-repo and cross-repo batch GraphQL builders; both + request `states: [OPEN, MERGED, CLOSED]`, `headRepository`, and `mergeQueueEntry`; + write actions append `--repo host/owner/repo` via `repoArgument(_:)`. +- `GithubPullRequest.swift`, `GithubMergeQueueEntry.swift`, + `CrossRepoPullRequestResponse.swift`, `GithubGraphQLPullRequestResponse.swift` — + decode models. +- `PullRequestMergeReadiness.swift` — blocker ordering (conflicts > changes requested > + failed checks > pending checks > blocked); `checksPending` counts + `breakdown.inProgress + breakdown.expected`. +- `PullRequestMergeQueueStatus.swift` — queue membership (open, non-draft, live entry + only), 1-based position, estimated-time labels. +- `GithubRemoteInfo.swift` — resolved host/owner/repo identity. + +Refresh pipeline: + +- `supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift` — + per-host buckets keyed by `RepoKey`, `KeyedDebouncer` (250 ms), `inflightHosts` + serialization, per-repo fallback, `allowedHeadRepositories` for the head-repo filter. +- `supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift` — + refresh scheduling; `lastSelectedWorktreeIDByRepo` (cooldown cancel + pruning); + `remoteConfigMonitors` / `RemoteConfigMonitoring` with a 2 s `remoteConfigDebouncer` + for git-remote-change refresh. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift` + — `resolveGithubRemoteInfos` (multi-remote candidates), `pullRequestsByWorktreeID` + (tri-state apply with explicit `updateValue(nil, forKey:)` clears), + `prRefreshFailedBatchRepositoryIDs`, UNKNOWN-mergeable preservation before the + equality check in `repositoryPullRequestsLoaded`, main-worktree merged filter + (`worktree.isMain` + `state == "MERGED"`), write-action repo resolution from + `pullRequest.url` via `GitClient.parseGithubRemoteInfo`. + +UI: + +- `supacode/Features/Repositories/Views/WorktreeRow.swift` — summary segments + Merged / Closed (orange) / Queued (brown, priority over merge readiness) / + merge-readiness label colored by `mergeStatusColor` (green / red / yellow). +- `PullRequestBadgeView.swift` (`closedColor = Color.orange`), + `PullRequestStatusButton.swift` + `ToolbarStatusView.swift` (toolbar status; + CLOSED early-returns like MERGED), `PullRequestChecksPopoverView.swift` + (`.pointerStyle(.link)` on PR title and check names), + `PullRequestChecksRingView.swift`, `WorktreePullRequestAccessoryView.swift`. + +Tests: `supacodeTests/PullRequestRefreshCoordinatorTests.swift`, +`BatchedPullRequestRefreshReducerTests.swift`, `GithubBatchPullRequestsTests.swift`, +`GithubCLIClientTests.swift`, `PullRequestMergeReadinessTests.swift`, +`PullRequestMergeQueueStatusTests.swift`, `WorktreeInfoWatcherManagerTests.swift`. + +User-facing behavior is documented in `docs/components/github-pull-requests.md` +(multi-remote preference order, head-repo filter, remote-change watching, merge queue, +check states). + +## Deviations from plan + +- #366's design survives intact; the only structural correction was #379's repo-key + grouping (the coordinator originally assumed distinct GitHub repos per local repo). +- #256's remote-resolution order was itself revised twice: #366 inverted it + (`git remote get-url` before `gh repo view`), and #456 replaced the single cached + remote with per-refresh multi-remote candidate resolution. +- #533's tri-state design shipped, but its clearing path was dead code until #538 + (see amendment 004) — the plan-level semantics only became effective there. + +## Open questions + +- #425 explicitly deferred tinting the toolbar status button brown for queued PRs + ("would require threading `isQueued` through `PullRequestStatusModel`"); no queued + handling exists in `PullRequestStatusButton.swift` / `ToolbarStatusView.swift` today, + so the follow-up was never picked up. Presumably still intentional (sidebar + popover + + accessory cover the surfaces). +- #425 did not port upstream's GHES < 3.8 "retry without `mergeQueueEntry`" fallback; + pointing Prowl at a pre-3.8 GitHub Enterprise Server would break the batch query. + Accepted risk per the PR, unverified against a real GHES. diff --git a/docs-ai/028-pr-status-tracking/002-merge-queue-and-fork-remotes.md b/docs-ai/028-pr-status-tracking/002-merge-queue-and-fork-remotes.md new file mode 100644 index 00000000..15b08eef --- /dev/null +++ b/docs-ai/028-pr-status-tracking/002-merge-queue-and-fork-remotes.md @@ -0,0 +1,55 @@ +# 028 — Amendment: Merge Queue State & Fork/Multi-Remote Lookup + +## Context + +After the wave-1 batching work, two gaps surfaced in what the pipeline could *see*: + +- Repos using GitHub merge queues showed a queued PR as a plain open PR — no signal + that it was mid-merge (upstream had added this as supacode #352). +- Fork workflows broke lookup: a repository whose PR lives on the `upstream` remote + (not `origin`) showed nothing (fork issue #452), remotes edited while the app ran + were never picked up (fork issue #463), and the fork-fallback matching could surface + an unrelated PR whose fork reused the same branch name. + +## Change + +- **PR #425** (2026-06-08, port of upstream #352 from the 2026-06-09 review batch): + `mergeQueueEntry { position estimatedTimeToMerge state }` added to both the + single-repo and cross-repo GraphQL queries, decoded via + `supacode/Clients/Github/GithubMergeQueueEntry.swift`. + `PullRequestMergeQueueStatus.swift` summarizes membership — queued only when open, + non-draft, with a live entry; 1-based position; "<1 min left" / "Cannot merge from + queue" / "Merge queue locked" labels. Sidebar shows a brown "Queued" text segment + (priority over the merge-readiness label), the checks popover an "In merge queue" row, + and the accessory badge tints brown. Fork adaptations vs upstream: text summary + instead of upstream's PR-icon sidebar, SF Symbol `arrow.triangle.merge` instead of a + bundled asset, and the field is requested unconditionally (no GHES < 3.8 retry + fallback). +- **PR #456** (2026-06-17, fixes fork issue #452): resolve *current* GitHub remotes per + refresh instead of the stale single-remote cache; query and merge fork/upstream + candidates preferring `origin`, then `upstream`, then other remotes alphabetically; + PR write actions resolve their target repo from the displayed PR's URL + (`GitClient.parseGithubRemoteInfo(pullRequest.url)`), so actions hit the repo that + owns the PR. +- **PR #469** (2026-06-17, fixes fork issue #463): `WorktreeInfoWatcherManager` gains + per-repository `RemoteConfigMonitoring` on the git config, a debounced (2 s) + remote-change event, refresh of PR state + code-host labels on change, and clearing of + stale badges when a repository loses its last GitHub remote. +- **PR #519** (2026-06-27): matches are filtered by the PR's `headRepository` against + the remote being checked (`allowedHeadRepositories` in + `PullRequestRefreshCoordinator.swift`); the earlier fork fallback that could surface + same-name branches from unrelated forks was removed and the behavior documented. + +## Refs + +- PRs #425, #456, #469, #519; fork issues #452, #463; upstream #352. +- Tests: `PullRequestMergeQueueStatusTests.swift`, `GitRemoteInfoTests` (via #456), + `WorktreeInfoWatcherManagerTests.swift`, `GithubBatchPullRequestsTests.swift`. +- Behavior: `docs/components/github-pull-requests.md` (remote preference order, + head-repo filter, remote watching, merge queue). + +## Current state + +As described; verified in the working tree 2026-07-12. Note the toolbar status button +still has no queued tint (deferred in #425, never picked up) and the GHES < 3.8 +fallback remains unported — both tracked in 001-action.md's Open questions. diff --git a/docs-ai/028-pr-status-tracking/003-status-fidelity-and-refresh-cadence.md b/docs-ai/028-pr-status-tracking/003-status-fidelity-and-refresh-cadence.md new file mode 100644 index 00000000..79eb367c --- /dev/null +++ b/docs-ai/028-pr-status-tracking/003-status-fidelity-and-refresh-cadence.md @@ -0,0 +1,54 @@ +# 028 — Amendment: Pending Checks, CLOSED State & Selection Cooldown + +## Context + +Late June brought a wave of "the label lies" reports: + +- "CI still running" and "all checks passed" both rendered as green "Mergeable" + (fork issue #462). +- PRs closed without merging vanished from the worktree row entirely — the GraphQL + queries only requested `[OPEN, MERGED]`. +- Switching between two worktrees of the same repository showed stale PR info for up to + 5 s: the selection cooldown compared the *global* previous worktree instead of the + per-repo one, so `lastSelectedWorktreeIDByRepo` was written but never read. + +## Change + +- **PR #496** (2026-06-24, fixes #462): `PullRequestMergeBlockingReason.checksPending(Int)` + in `supacode/Clients/Github/PullRequestMergeReadiness.swift`, detected when + `breakdown.inProgress > 0` with no failures; `WorktreeRow.mergeStatusColor` renders it + yellow. Priority: failed checks > pending checks > mergeable. +- **PR #500** (2026-06-24, follow-up): required commit-status contexts declared via + branch protection but not yet reporting surface as `EXPECTED`, not `IN_PROGRESS`, and + fell through to green. Fix folds them in: + `pendingChecks = breakdown.inProgress + breakdown.expected`. +- **PR #499** (2026-06-24): `WorktreeInfoWatcherManager.setSelectedWorktreeID` now + compares against `lastSelectedWorktreeIDByRepo[repo]`, cancelling the cooldown (and + refreshing immediately) only when the selection actually changed worktree within the + repo; reselecting the same worktree respects the cooldown. +- **PR #501** (2026-06-24, hygiene follow-up): `setWorktrees` prunes + `lastSelectedWorktreeIDByRepo` entries for removed repositories, mirroring the + adjacent cooldown cleanup. +- **PR #505** (2026-06-25): CLOSED PRs become visible — `states: [OPEN, MERGED, CLOSED]` + in both GraphQL query paths in `GithubCLIClient.swift`; `PullRequestBadgeView` gains a + CLOSED badge, `PullRequestStatusButton` stops hiding CLOSED (early-returns like + MERGED), `WorktreeRow` shows a "Closed" summary segment. The PR body specified red, + but an in-PR review follow-up commit (`79a32430`) changed `closedColor` to + `Color.orange` before merge. +- **PRs #521 (2026-06-28) / #532 (2026-07-08)**: checks-popover affordance polish by the + same community contributor as the UI refresh (entry 033) — underline + blue hover + + `.pointerStyle(.link)` on CI check names, then the same pointer treatment and a unified + accessibility hint on the PR title. + +## Refs + +- PRs #496, #499, #500, #501, #505, #521, #532; fork issue #462. +- Tests: `PullRequestMergeReadinessTests.swift` (pending/expected cases), + `WorktreeInfoWatcherManagerTests.swift` (cooldown paths). +- Behavior: `docs/components/github-pull-requests.md` (check states, merge readiness). + +## Current state + +As described; verified in the working tree 2026-07-12. The "N checks running" wording is +kept even when the count includes `EXPECTED` checks — a deliberate minimal-change call in +#500 (such checks are rare and resolve within seconds). diff --git a/docs-ai/028-pr-status-tracking/004-flicker-and-no-pr-semantics.md b/docs-ai/028-pr-status-tracking/004-flicker-and-no-pr-semantics.md new file mode 100644 index 00000000..bc230431 --- /dev/null +++ b/docs-ai/028-pr-status-tracking/004-flicker-and-no-pr-semantics.md @@ -0,0 +1,62 @@ +# 028 — Amendment: Flicker Elimination & Explicit No-PR Semantics + +## Context + +Two visible flickers remained after all previous waves, both rooted in the pipeline +conflating distinct states: + +1. Sidebar PR labels briefly disappeared on every 30/60 s refresh cycle: a transient + empty result from `resolveGithubRemoteInfos`, or a partial fetch failure across + multiple remotes/hosts, was treated the same as "confirmed: no PR" and cleared state. +2. The badge flashed red "Blocked" before settling: GitHub computes GraphQL `mergeable` + asynchronously and returns `"UNKNOWN"` mid-calculation, which + `PullRequestMergeReadiness` mapped to `.blocked`. + +## Change + +The chain is #533 → #538 → #539: + +- **PR #533** (2026-07-05, community contribution): introduce tri-state semantics. + `Outcome.refreshed` carries `confirmedNoPrBranches: Set<String>`, computed by the + coordinator only when *all* candidate repos for a branch were queried successfully. + Apply rules: branch in `prsByBranch` → update; branch in `confirmedNoPrBranches` → + clear; in neither (partial failure) → preserve existing state. A `Set` was chosen + over `[String: GithubPullRequest?]` deliberately, because a Swift dictionary's + `dict[key] = nil` removes the key rather than storing `.some(nil)`. +- **PR #538** (2026-07-05, supersedes #533, keeping its commits): the design was sound + but the implementation had two gaps — + 1. The confirmed-no-PR clear was dead code: `prsByWorktreeID[worktreeID] = nil` on a + `[Worktree.ID: GithubPullRequest?]` removed the key, so the downstream handler + (which iterates present keys) never saw the clear. Fixed with + `updateValue(nil, forKey:)`; a red-check test proved the old version fails. + 2. Cross-host suppression was arrival-order dependent: a `.failed` batch arriving + before the final `.refreshed` outcome left the accumulated confirmed set intact, so + a healthy host could still clear a PR living on the failed host. Failed batches are + now tracked per repository (`prRefreshFailedBatchRepositoryIDs`) and confirmed + clears are suppressed whenever any batch failed, in either order. + All #533 test call sites had passed `confirmedNoPrBranches: []` — the non-empty path + was untested, which is why the no-op was invisible; #538 added reducer and coordinator + coverage for both orderings. +- **PR #539** (2026-07-08): preserve the last-known `mergeable` and `mergeStateStatus` + at the reducer level when the incoming value is `UNKNOWN`, applied in + `repositoryPullRequestsLoaded` before the equality check so the UI never sees the + intermediate state. All other fields (title, checks, commits) update normally; a first + load with `UNKNOWN` stays `UNKNOWN` (nothing to preserve). + +## Refs + +- PRs #533, #538, #539. +- Code: `supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift` + (`pullRequestsByWorktreeID`, `prRefreshFailedBatchRepositoryIDs`, the UNKNOWN + preservation), `supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift` + (confirmed-set computation). +- Tests: `BatchedPullRequestRefreshReducerTests.swift`, + `PullRequestRefreshCoordinatorTests.swift`, `RepositoriesFeatureTests.swift` + (UNKNOWN-preservation cases). + +## Current state + +As described; verified in the working tree 2026-07-12: the explicit clear uses +`updateValue(nil, forKey:)` with an in-code comment documenting the nil-literal pitfall, +failed-batch tracking suppresses confirmed clears order-independently, and UNKNOWN +`mergeable` carries the previous known value forward. diff --git a/docs-ai/029-active-agents-panel/000-plan.md b/docs-ai/029-active-agents-panel/000-plan.md new file mode 100644 index 00000000..24268b04 --- /dev/null +++ b/docs-ai/029-active-agents-panel/000-plan.md @@ -0,0 +1,136 @@ +# 029 — Active Agents Panel: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-09 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #274, #335, #336, #344, #363, #386, #475 | +| **Sources** | `doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md` (absorbed here; original removed in the docs-ai migration), `doc-onevcat/active-agents-panel-task-log.md` (absorbed here; original removed in the docs-ai migration), change-list entry 2026-05-09 (Ghostty fork patch), PR descriptions | +| **Related** | [030-agent-status-detection](../030-agent-status-detection/000-plan.md), [013-prowl-cli/002-agents-command](../013-prowl-cli/002-agents-command.md), `docs/components/active-agents.md`, `docs/components/agent-detection.md` | + +## Background + +Before this work, Prowl's notion of "is an agent running" was weak: a two-state +`idle`/`running` per-worktree status (`supacode/Domain/WorktreeTaskStatus.swift`) whose +only signal source was Ghostty's OSC progress state. That meant (a) nothing was detected +when shell integration was missing or the agent did not report progress; (b) multiple +split panes in one tab each running an agent could not be distinguished; (c) there was +no `blocked` state (agent waiting for user input); (d) there was no cross-worktree +global view of running agents. + +The goal was a new **Active Agents** panel: docked at the bottom of the left sidebar +(below the worktree list), drag-resizable, collapsible via a footer button with a +slide-in-from-bottom animation, listing **every** running agent across all +worktrees/tabs/panes with a four-level status (working / blocked / done / idle), and +click-to-focus jumping to the owning worktree → tab → pane. + +The reference implementation is [herdr](https://github.com/ogulcancelik/herdr) +(Rust, ratatui). The plan deeply borrowed its hybrid process-detection + +screen-heuristics algorithm, adapted to Swift/GhosttyKit. Work was split into +**Phase 1: detection layer rewrite** (the part that decides whether the whole feature +is trustworthy) and **Phase 2: UI and wiring**. + +## Goals + +- Per-surface (pane-level) agent detection: identity, liveness, and state. +- Four display states: `working`, `blocked`, `done` (unread idle), `idle`, where + `done` is derived as `idle && !seen`. +- Sidebar panel listing all detected agents globally; click a row to focus its surface. +- Panel height persisted and drag-resizable; hidden/shown state persisted; animated + slide from the bottom edge. +- Full unit-test coverage for the pure detection logic (classifier, screen heuristics, + state stabilization), porting herdr's `detect.rs` test fixtures. + +### Non-goals (deferred) + +- Hook/socket integration where agents self-report authoritative state (herdr's + socket API model) — explicitly out of scope for Phase 1/2; treated as the eventual + fix for the known fragility of text heuristics. (This later materialized as entry + [045-native-agent-session-detection](../045-native-agent-session-detection/000-plan.md).) +- Automated end-to-end tests against a real pty — manual smoke testing instead. + +## Design / Approach + +### Prerequisite: Ghostty fork with a PID export + +GhosttyKit's C API did not expose a surface's child process PID, which herdr-style +process detection requires. Decision: create an `onevcat/ghostty` fork with +per-upstream-tag patched branches (`release/v<TAG>-patched`, starting at +`release/v1.3.1-patched`), carrying a small patch that exports +`ghostty_surface_pid()`. Branches are never history-rewritten; upgrading to a new +upstream tag means creating a new branch and cherry-picking the patch set. The upgrade +procedure is a living runbook, now at +[`docs-ai/007-ghostty-embedding-integration/ghostty-fork-sync.md`](../007-ghostty-embedding-integration/ghostty-fork-sync.md). + +### Phase 1 — detection layer + +Three-layer responsibility split (from herdr's INTEGRATIONS.md): + +- **Process detection owns identity and liveness**: read the pty's foreground process + group, list PIDs in that group (`proc_pidinfo`, `proc_listallpids`), recover + `argv[0]`/cmdline via `sysctl(KERN_PROCARGS2)` — + `supacode/Infrastructure/AgentDetection/ProcessDetection.swift`. +- **Agent classifier** maps process names to a `DetectedAgent` enum (initially herdr's + full list of 11: pi, claude, codex, gemini, cursor, cline, opencode, copilot, kimi, + droid, amp), including wrapped-runtime handling (`node /path/to/codex` → codex) with + priority scoring — `supacode/Infrastructure/AgentDetection/AgentClassifier.swift`. +- **Screen heuristics decide fallback state**: per-agent pure functions + `(String) -> AgentRawState` over the viewport text (via `ghostty_surface_read_text`), + checking blocked → working → default idle, ported from herdr `detect.rs` together + with its test fixtures — `supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift`. + +State machine: internal `AgentRawState = {working, blocked, idle, unknown}` maps to a +display state `{working, blocked, done, idle}`; `seen` flips false when a +working/blocked → idle transition happens while the surface is not foreground, +producing the `done` badge. Stabilization rules: a 1.2 s sticky "working hold" for +Claude (tool-result rendering briefly looks idle), and 6 consecutive process-probe +misses before releasing a detected agent. Polling: 300 ms tick with an agent detected, +500 ms otherwise; process probes throttled to ~5 s unless a change is suspected. + +Multi-language strategy: prefer language-neutral signals (spinner glyphs, box-drawing +chars, `[y/n]`, key names like `esc`/`ctrl+c`) over English phrases inside each +detector; Kimi (the only realistically localized CLI) gets a multi-pattern list that +can grow Chinese patterns later. + +### Phase 2 — UI and wiring + +- Layout: plain `VStack` in `SidebarListView` with the panel conditionally rendered + under `.transition(.move(edge: .bottom))` — deliberately **not** a `SplitView` + (SplitView rebuilds the hierarchy on hidden/visible flips and cannot animate the + slide). The panel carries its own top-edge drag handle; height clamped so the + repository list keeps a minimum visible height. +- Persistence via `@Shared(.appStorage(...))` for panel hidden state and height. +- TCA: `ActiveAgentsFeature` mounted as a child of `RepositoriesFeature` (sidebar + scope; no need to lift to `AppFeature`), fed by new `TerminalClient` events. +- Row UI: agent icon + name + status pill using system colors only; sort by status + priority in the reducer; empty state text when nothing is detected. +- Click-to-focus: a new `TerminalClient` `focusSurface` command that selects the + worktree, switches the tab, and focuses the target surface; focusing also flips + `seen` so `done` demotes to `idle`. + +## Alternatives & decisions + +- **Swift port of herdr's `detect.rs` vs. embedding a Rust dylib** — port chosen: + the detection code is ~95% `.contains(...)` string checks, while embedding would + need a cross-compile pipeline, universal dylib signing, library-validation + exemptions, and per-tick FFI marshalling; customization (e.g. Kimi Chinese + patterns) would also become far more painful. A periodic "drift-check" against + upstream herdr `detect.rs` was proposed instead of binary reuse. +- **Ghostty fork model** — per-version patched branches (`release/v<TAG>-patched`) + chosen so no branch is ever force-pushed and every version stays traceable; + accepted cost: a cherry-pick per upstream upgrade. +- **Screen-text heuristics accepted as a maintenance cost** — agent CLI UI strings + are stable but can change on upgrades; mitigated by fixture tests and the + language-neutral-signal preference, with hook integration as the long-term fix. +- **No automated e2e** — real-pty end-to-end testing judged too expensive; pure + functions get exhaustive unit tests, the rest is manual smoke. + +## Amendments + +- Updated 2026-05-25: keyboard navigation (⌃⌥↑/↓), selection flicker fix, and + plain-folder selection fix — see [002-selection-and-keyboard-navigation.md](002-selection-and-keyboard-navigation.md) +- Updated 2026-06-04: row repo/branch resolved from the agent's cwd, and an optional + tab-title row display setting — see [003-row-display-resolution.md](003-row-display-resolution.md) +- Updated 2026-06-19: agent working/blocked state folded into the worktree running + indicator — see [004-agent-busy-running-indicator.md](004-agent-busy-running-indicator.md) diff --git a/docs-ai/029-active-agents-panel/001-action.md b/docs-ai/029-active-agents-panel/001-action.md new file mode 100644 index 00000000..4c355619 --- /dev/null +++ b/docs-ai/029-active-agents-panel/001-action.md @@ -0,0 +1,85 @@ +# 029 — Active Agents Panel: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-09 | Created `onevcat/ghostty` fork branch `release/v1.3.1-patched` exporting `ghostty_surface_pid()`; Prowl submodule repointed to the fork | change-list 2026-05-09 | +| 2026-05-09 | During bring-up, added a second fork export `ghostty_surface_foreground_process_group()` (pty `tcgetpgrp`) after `proc_bsdinfo.e_tpgid` returned nil for the shell PID in manual testing | task log, PR #274 | +| 2026-05-10 | Shipped Phases 0–2: detection layer (`ProcessDetection`, `AgentClassifier`, `ScreenHeuristics`), domain model (`DetectedAgent`, `AgentRawState`, `PaneAgentState`), per-surface detection loop in `WorktreeTerminalState`, `ActiveAgentsFeature` + panel/row views, footer toggle, ⌘⌥P shortcut, auto-show setting, click-to-focus, agent icons | PR #274 | +| 2026-05-24 | ⌃⌥↑/↓ Select Next/Previous Agent shortcuts | PR #335 (see [002](002-selection-and-keyboard-navigation.md)) | +| 2026-05-24 | Fixed tab flip + highlight flicker when selecting agents (focus-before-select ordering) | PR #336 (see [002](002-selection-and-keyboard-navigation.md)) | +| 2026-05-25 | Fixed row activation for agents in plain folders | PR #344 (see [002](002-selection-and-keyboard-navigation.md)) | +| 2026-05-28 | Row repo/branch resolved from the agent's working directory, not the owning tab's worktree | PR #363 (see [003](003-row-display-resolution.md)) | +| 2026-06-04 | Setting to show tab titles instead of branch names in agent rows | PR #386 (see [003](003-row-display-resolution.md)) | +| 2026-06-19 | Agent working/blocked state folded into the worktree running indicator (`taskStatus`) | PR #475 (see [004](004-agent-busy-running-indicator.md)) | + +## Outcome & current state (as of 2026-07-12) + +- **Panel feature**: `supacode/Features/ActiveAgents/` — `Reducer/ActiveAgentsFeature.swift` + (entries as `IdentifiedArrayOf<ActiveAgentEntry>`, `focusedSurfaceID` keyboard anchor, + `selectNextEntry`/`selectPreviousEntry`, panel visibility/height via + `@Shared(.appStorage)`), `Views/ActiveAgentsPanel.swift`, `Views/ActiveAgentRow.swift` + (status pill uses a `BaguaWorkingIndicator` animation), `Models/ActiveAgentEntry.swift` + (carries `workingDirectory: URL?` for display resolution). +- **Detection layer**: `supacode/Infrastructure/AgentDetection/` — `ProcessDetection.swift`, + `AgentClassifier.swift`, `ScreenHeuristics.swift` (single file; per-agent detectors are + private pure functions exposed as `DetectedAgent.detectState(in:)`). The directory has + since grown session-identity files (`AgentSessionProfile.swift`, + `AgentSessionResolver.swift`, `AgentPidArtifacts.swift`, `OpenCodeSessionStore.swift`) + belonging to [045](../045-native-agent-session-detection/000-plan.md), and + `supacode/Domain/AgentDetection/AgentDetectionSchedule.swift` from the detection + hardening tracked in [030](../030-agent-status-detection/000-plan.md). +- **Domain model**: `supacode/Domain/AgentDetection/` — `DetectedAgent.swift` (now 12 + cases; `qwen` added later, see [030](../030-agent-status-detection/000-plan.md)), + `AgentRawState.swift`, `PaneAgentState.swift` (including `isBusy` from PR #475). +- **Ghostty bridge**: `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — + `childPID()` and `foregroundProcessGroupID()` call the fork exports + `ghostty_surface_pid` / `ghostty_surface_foreground_process_group` directly (the + initial `dlsym` indirection used before the xcframework rebuild is gone); + `readViewportText()` feeds the heuristics. +- **Terminal integration**: `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` + runs the per-surface detection loop and owns `tabAgentBusyById`; + `supacode/Clients/Terminal/TerminalClient.swift` carries `agentEntryChanged` / + `agentEntryRemoved` events and the `focusSurface` command. +- **Wiring**: footer toggle in `supacode/Features/Repositories/Views/SidebarFooterView.swift` + (`person.crop.rectangle.stack[.fill]`); shortcuts `toggleActiveAgentsPanel` (⌘⌥P), + `selectNextActiveAgent`, `selectPreviousActiveAgent` in `supacode/App/AppShortcuts.swift`; + settings `autoShowActiveAgentsPanel` and `showActiveAgentTabTitles` in + `supacode/Features/Settings/Models/GlobalSettings.swift`; row display resolution in + `supacode/Features/Repositories/Views/SidebarListView.swift` + (`activeAgentRowDisplays` / `resolveWorktreeID(forWorkingDirectory:in:)`). +- User-facing behavior is documented in `docs/components/active-agents.md` and + `docs/components/agent-detection.md`; the `prowl agents` CLI view over the same data + is [013-prowl-cli/002-agents-command](../013-prowl-cli/002-agents-command.md). + +## Deviations from plan + +- **No `ActiveAgentsRegistry`**: the plan called for a top-level `@Observable` registry + (`Features/ActiveAgents/Models/ActiveAgentsRegistry.swift`). Instead, entries flow as + `TerminalClient` events (`agentEntryChanged`/`agentEntryRemoved`) straight into + `ActiveAgentsFeature` state; no registry file exists. +- **No status-priority sorting**: the planned reducer-side sort + (blocked → working → done → idle) was listed as a follow-up in PR #274 and was never + implemented; entries stay in insertion order in the `IdentifiedArray`. +- **Footer toggle symbol**: plan proposed `rectangle.bottomthird.inset[.filled]`; shipped + `person.crop.rectangle.stack[.fill]` because the planned symbol rendered empty in the + hidden state on the tested system. +- **Extra fork export**: the plan required only `ghostty_surface_pid`; + `ghostty_surface_foreground_process_group` was added during implementation because + `e_tpgid` was unreliable. +- **Extra scope in #274**: ⌘⌥P shortcut, View-menu entry, and the auto-show setting were + not in the plan (which only specified the footer toggle). +- **Single `ScreenHeuristics.swift`**: the optional per-agent `Detectors/` file split was + not done. +- Agent display names use short lowercase command tokens (`claude`, `codex`, …) by + decision — the panel is a compact terminal-status surface, not product branding. + +## Open questions + +- The planned status-priority sorting of panel rows (blocked first) is still absent as + of 2026-07-12; unclear whether it was dropped deliberately or just never picked up. +- PR #274 listed "off-main-actor process scanning" as a follow-up (polling happened on + the MainActor per surface every 300–500 ms); whether the current scheduling + (`AgentDetectionSchedule`, entry [030](../030-agent-status-detection/000-plan.md)) + fully addressed this was not verified here. diff --git a/docs-ai/029-active-agents-panel/002-selection-and-keyboard-navigation.md b/docs-ai/029-active-agents-panel/002-selection-and-keyboard-navigation.md new file mode 100644 index 00000000..18e743e9 --- /dev/null +++ b/docs-ai/029-active-agents-panel/002-selection-and-keyboard-navigation.md @@ -0,0 +1,53 @@ +# 029 — Amendment: Selection & Keyboard Navigation (2026-05-24/25) + +## Context + +After the panel shipped (#274), selecting agents had two gaps: there was no keyboard +way to walk the list, and clicking a row produced a visible tab flip plus a wrong-agent +highlight flash. A third issue surfaced with plain folders (non-git directories, entry +[010](../010-plain-folder-support/000-plan.md)): tapping their rows treated the plain +repository id as a git worktree and selection failed. + +## Change + +**⌃⌥↑/↓ navigation (PR #335, 2026-05-24).** Two new user-customizable commands, +*Select Next Agent* / *Select Previous Agent*, registered across all binding tables in +`supacode/App/AppShortcuts.swift` and added to the sidebar menu. `ActiveAgentsFeature` +gained a `focusedSurfaceID` anchor, `selectNextEntry` / `selectPreviousEntry` / +`focusedSurfaceChanged` actions, and a pure `entryID(navigatingFrom:direction:in:)` +helper (step + wrap-around; ↓ starts at the first entry and ↑ at the last when the +anchor is not in the list). Navigation re-dispatches the existing `entryTapped` flow so +no jump logic is duplicated. ⌃⌥ was chosen because ⌘⌥↑↓ (split panes) and ⌃⌘↑↓ +(worktrees) were taken. + +**Selection flicker fix (PR #336, 2026-05-24).** Selecting an entry had merged +`selectWorktree` and `focusSurface` effects; selection landed first, so the worktree +appeared showing its previously-focused tab before the focus switched — a visible tab +flip, and the panel highlight flashed the wrong agent. An earlier mask in #335 (reading +the reducer's focus anchor) failed for mouse clicks because `focusChanged` events are +deduplicated per worktree, leaving the anchor stale. The fix reorders at the source in +`RepositoriesFeature.entryTapped`: `focusSurface` first (pre-selects the target tab +while the worktree is still invisible), then `selectWorktree(focusTerminal: true)`. +`ActiveAgentsPanel`'s dimming logic reverted to plain `selectedSurfaceID`; +`focusedSurfaceID` remains keyboard-navigation-only. + +**Plain-folder selection (PR #344, 2026-05-25, fixes #342).** Row activation for +agents running in plain folders now selects the plain repository instead of treating +its id as a git worktree, preserving the surface-first focus ordering from #336. + +## Refs + +- PR #335 (merged 2026-05-24), PR #336 (merged 2026-05-24), PR #344 (merged 2026-05-25) +- Tests: `ActiveAgentsFeatureTests` (step/wrap/empty-list/anchor), + `RepositoriesFeatureTests` (focus-before-select ordering; plain-folder regression) + +## Current state + +`entryID(navigatingFrom:direction:in:)` and the anchor logic live in +`supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift`; the shortcut ids +are `select_next_active_agent` / `select_previous_active_agent` in +`supacode/App/AppShortcuts.swift`. The `entryTapped` handling (focus-before-select +ordering, plain-folder branch) has moved to +`supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` in the +reducer split (see [015-repositories-feature-refactor](../015-repositories-feature-refactor/000-plan.md)), +and now also handles Canvas-mode focus. diff --git a/docs-ai/029-active-agents-panel/003-row-display-resolution.md b/docs-ai/029-active-agents-panel/003-row-display-resolution.md new file mode 100644 index 00000000..dd2feda6 --- /dev/null +++ b/docs-ai/029-active-agents-panel/003-row-display-resolution.md @@ -0,0 +1,45 @@ +# 029 — Amendment: Row Display Resolution (2026-05-28 / 2026-06-04) + +## Context + +Each agent row labels which repository/branch the agent belongs to. Originally the +label came from the worktree owning the agent's terminal **tab**, so an agent launched +after `cd`-ing into a different repo inside a tab showed the wrong repo/branch. +Separately, users who identify panes by tab title (rather than branch) had no way to +see titles in the panel. + +## Change + +**Repo/branch from the agent's cwd (PR #363, 2026-05-28).** `ActiveAgentEntry` gained +`workingDirectory: URL?`, captured from the surface's inherited config when the entry +is emitted (`WorktreeTerminalState.activeAgentEntry`). Display resolution is a +three-tier lookup in `SidebarListView`: + +1. cwd inside a known repo/worktree → use that worktree as the display key (name and + live branch label via the existing metadata path); +2. cwd known but outside every repo → derive a name from the last path component; +3. cwd unknown → fall back to the surface's owning worktree (previous behavior). + +`ActiveAgentEntry.worktreeID` is deliberately left untouched: it also drives +`focusSurface`/`selectWorktree`, and the surface physically lives in the tab's +worktree, so the cwd resolution is display-only. + +**Tab-title display setting (PR #386, 2026-06-04, refs #385).** A persisted setting +(`showActiveAgentTabTitles` in `GlobalSettings`, default off) swaps the row +subtitle/tooltip between branch name (default) and tab title. + +## Refs + +- PR #363 (merged 2026-05-28), PR #386 (merged 2026-06-04) +- Tests: `RepositorySectionViewTests` (all three cwd tiers, nested-worktree deepest + match, nil-cwd fallback), `SettingsFeatureTests`, `AppFeatureSettingsChangedTests`, + `ActiveAgentsFeatureTests` (subtitle/help behavior) + +## Current state + +Resolution helpers `activeAgentRowDisplays` / `activeAgentRowDisplay` / +`resolveWorktreeID(forWorkingDirectory:in:)` live in +`supacode/Features/Repositories/Views/SidebarListView.swift` as static pure functions; +`ActiveAgentsPanel` consumes precomputed per-entry displays plus a `showTabTitles` +flag. The setting is wired through `supacode/Features/Settings/Models/GlobalSettings.swift` +and `supacode/Features/Settings/Reducer/SettingsFeature.swift`. diff --git a/docs-ai/029-active-agents-panel/004-agent-busy-running-indicator.md b/docs-ai/029-active-agents-panel/004-agent-busy-running-indicator.md new file mode 100644 index 00000000..163ed03a --- /dev/null +++ b/docs-ai/029-active-agents-panel/004-agent-busy-running-indicator.md @@ -0,0 +1,50 @@ +# 029 — Amendment: Agent Busy Folded into the Running Indicator (2026-06-19) + +## Context + +The worktree **running indicator** (sidebar row spinner and `prowl list`'s +`task.status`) was driven solely by OSC 9;4 command progress. Claude Code never emits +OSC 9;4 while it works, so a busy pane — especially one running a background workflow +(turn ended, input box visible, subagents still churning) — showed as idle. Agent +detection already knew the pane was busy, but the signal only fed the Active Agents +panel, never `taskStatus`. + +## Change + +PR #475 (merged 2026-06-19): + +1. **Agent-agnostic fold.** A per-tab `tabAgentBusyById` aggregate in + `WorktreeTerminalState` is true when any surface in the tab has a detected agent + whose stabilized `displayState` is working or blocked (`PaneAgentState.isBusy`). + It is OR-ed into `taskStatus` next to the OSC-driven `tabIsRunningById`, recomputed + on every detection tick, on agent release, and on surface/tab teardown, emitting a + task-status change only when the merged value flips. Both consumers (sidebar and + `prowl list`) read `taskStatus` live, so no extra wiring was needed. +2. **Claude background-workflow footer.** `detectClaude` additionally scans the + below-prompt footer for Claude's `N/M agents done` status-line marker and reports + working — anchored to the footer region so conversation text quoting the phrase + cannot trip it. The marker is Claude-version-specific (taken from Claude Code + v2.1.181's `statusText` template) and pinned by a test fixture. +3. **Documented-only alternative.** A Claude Code hook emitting OSC 9;4 via + `terminalSequence` is described in docs as the restyle-proof, lower-latency option; + no app code. + +Per the agreed design, working + blocked + background workflows all count as busy and +merge into the single existing running indicator — no new CLI field or icon. + +## Refs + +- PR #475 (merged 2026-06-19) +- Tests: `ScreenHeuristicsTests` (workflow footer working / idle footer / mid-conversation + false-positive guard), `PaneAgentStateTests` (`isBusy`), `WorktreeTerminalManagerTests` + (fold, single emission, teardown clearing) +- Detection-side status semantics evolution is tracked in + [030-agent-status-detection](../030-agent-status-detection/000-plan.md); the CLI + consumer is [013-prowl-cli/002-agents-command](../013-prowl-cli/002-agents-command.md). + +## Current state + +`tabAgentBusyById` and the fold live in +`supacode/Features/Terminal/Models/WorktreeTerminalState.swift`; `isBusy` in +`supacode/Domain/AgentDetection/PaneAgentState.swift`; the workflow-footer heuristic in +`supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift`. diff --git a/docs-ai/030-agent-status-detection/000-plan.md b/docs-ai/030-agent-status-detection/000-plan.md new file mode 100644 index 00000000..50a2193f --- /dev/null +++ b/docs-ai/030-agent-status-detection/000-plan.md @@ -0,0 +1,115 @@ +# 030 — Agent Status Detection: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-09 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #274 (detection layer, shared with 029), #277, #283, #285, #354, #355, #438, #440, #441, #483, #484 (reverted), #515 | +| **Sources** | `doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md` (detection design absorbed here, panel/UI design in [029](../029-active-agents-panel/000-plan.md); original removed in the docs-ai migration), change-list 2026-05-09 Ghostty fork-patch entry (ledger: [upstream-ledger.md](../017-upstream-sync-process/upstream-ledger.md)), PR descriptions | +| **Related** | [029-active-agents-panel](../029-active-agents-panel/000-plan.md), [045-native-agent-session-detection](../045-native-agent-session-detection/000-plan.md) (successor wave), [013-prowl-cli](../013-prowl-cli/000-plan.md) (`prowl agents`), [035-protected-terminal-close](../035-protected-terminal-close/000-plan.md), [ghostty-fork-sync.md](../007-ghostty-embedding-integration/ghostty-fork-sync.md), `docs/components/agent-detection.md` | + +## Background + +The Active Agents panel ([029](../029-active-agents-panel/000-plan.md)) needs to know, per +terminal pane, *which* coding agent is running and whether it is **working**, **blocked** +(waiting for user input, e.g. a permission prompt), or **idle**. Nothing in the app had +that signal: Ghostty's embedded C API did not even expose a surface's child PID, and the +agents themselves report nothing to the host (Claude Code does not emit OSC 9;4 progress +while it works). + +The reference implementation was [herdr](https://github.com/ogulcancelik/herdr) +(Rust/ratatui), whose hybrid process-detection + screen-heuristics model was adapted to +Swift/GhosttyKit. Detection was Phase 0–1 of the Active Agents plan; this entry tracks the +detection layer itself and its long tail of hardening. + +## Goals + +- Identify the agent in each pane (Claude, Codex, Gemini, Cursor, Cline, OpenCode, + Copilot, Kimi, Droid, Amp, Pi at v1) with **zero agent-side setup** — no hooks, no + wrappers, no config in the agent. +- Classify per-pane status into `working` / `blocked` / `idle` from what is already + observable (process table + rendered screen). +- Keep the classification logic pure functions, fully unit-testable without Ghostty, a + pty, or async (herdr's `detect.rs` fixtures ported as the test base). +- Bound the cost: detection runs continuously across every open pane. + +### Non-goals + +- **Hook/socket self-reporting** (agents pushing authoritative semantic state) — called + out in the plan as the root fix for UI-text fragility, deliberately deferred. +- Localization-proof detection: heuristics match agent CLI UI chrome (English constants); + breakage on agent UI changes was accepted as a known maintenance cost, same trade as + herdr. + +## Design / Approach + +- **Fork C API (hard prerequisite).** Ghostty exposes no surface child PID, so the + submodule moved to `onevcat/ghostty` branch `release/v1.3.1-patched` carrying + `ghostty_surface_pid(ghostty_surface_t)` and + `ghostty_surface_foreground_process_group(ghostty_surface_t)` (the latter switched to + `tcgetpgrp` on the pty fd for reliability). Recorded in the upstream ledger + (2026-05-09 entry); the cherry-pick-forward upgrade procedure is the + [ghostty-fork-sync runbook](../007-ghostty-embedding-integration/ghostty-fork-sync.md). +- **Two-stage detection with split authority** (herdr's model): + 1. **Process probe** owns *identity and liveness*: enumerate the pane's foreground + process group (`proc_listallpids` / `proc_pidinfo` / `KERN_PROCARGS2`), then + `AgentClassifier` scores candidates — argv[0] highest, then process name, then + command-line tokens — so agents launched through wrappers (node, bun, python, bash) + are still found. + 2. **Screen heuristics** own *state*: per-agent pure functions + `(String) -> AgentRawState` scan the last ~24 non-blank lines for the agent's own UI + chrome ("esc to interrupt", permission prompts, spinner glyphs), checked in priority + order blocked → working → default idle. Glyph-class signals (braille/spinner frames) + are ranked above text phrases as a layered defense against localized UIs. +- **Stabilization**, because both stages flicker: + - presence: release a detected agent only after 6 consecutive probe misses (tool + subprocesses briefly replace the agent in the foreground group); + - state: a short hold before trusting a raw working → idle flip (v1: 1.2 s, + Claude-only; agents blank their working cues between steps). +- **Polling loop** per surface on the MainActor: ~300 ms while an agent is detected, a + slower cadence otherwise, reading the screen via the surface bridge. +- **Extensibility contract**: supporting a new agent = add an enum case, a classifier + mapping, and a detector + fixtures. + +### Status signals in context + +Prowl ends up with two independent per-pane status sources, and the distinction matters: + +- **Command/task progress** (`WorktreeTaskStatus` running/idle) comes from Ghostty's + OSC 9;4 progress reports — i.e. *agent-self-reported*, fully decoupled from terminal + rendering, which is why a tab can flip to idle before the pane finishes painting. +- **Agent status** (this entry) is *observed* from the process table and the rendered + screen, and exists precisely because major agents (Claude Code) never emit OSC 9;4. + +The two were later OR-ed together for the worktree running indicator (#475, entry 029); +the failed attempt to extend the first signal to plain commands is +[003-plain-command-running-indicator.md](003-plain-command-running-indicator.md). + +## Alternatives & decisions + +- **Hook-driven presence rejected.** Upstream supacode went the hook route: Kiro/Pi + agent hooks were reviewed and skipped in the 2026-05-08 ledger batch, and the whole + hook-driven coding-agent integration track (upstream #307/#311/#317/#330/#374) was + skipped in the 2026-06-09 batch — it relies on upstream settings/hook modules the fork + does not carry, and hooks require per-agent setup. The fork's process+screen scan + needs none. +- **Fork patch over heuristic PID discovery.** An authoritative surface child PID was a + ≤30-line Zig patch; guessing from the shell PID's descendants was rejected. Cost: the + patch must be cherry-picked on every Ghostty submodule upgrade (runbook above). +- **Screen-text fragility accepted.** Detector strings are agent-rendered UI constants; + each agent CLI UI revision may require a detector update. Accepted explicitly, with the + fixture-heavy test suite as the safety net. +- **Keep the fork's own stabilization model.** The 2026-06-12 herdr upstream review + (v0.6.10) decided against porting herdr's later detection refactor; the fork keeps its + own stabilizer, including the deliberate 3 s working hold + (see [002](002-stability-and-scheduling.md)). + +## Amendments + +- Updated 2026-06-13: working hold generalized to all agents and widened to 3 s, viewer + overlays reclassified as no-signal frames, and polling made lazily scheduled — see + [002-stability-and-scheduling.md](002-stability-and-scheduling.md) +- Updated 2026-06-23: plain-command running-indicator fallback (#484) merged, then + reverted; successor design tracked in issue #495 — see + [003-plain-command-running-indicator.md](003-plain-command-running-indicator.md) diff --git a/docs-ai/030-agent-status-detection/001-action.md b/docs-ai/030-agent-status-detection/001-action.md new file mode 100644 index 00000000..e2557274 --- /dev/null +++ b/docs-ai/030-agent-status-detection/001-action.md @@ -0,0 +1,88 @@ +# 030 — Agent Status Detection: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-09 | `onevcat/ghostty` branch `release/v1.3.1-patched` created: `ghostty_surface_pid` + `ghostty_surface_foreground_process_group` C APIs, `tcgetpgrp` foreground-group patch | ledger 2026-05-09 | +| 2026-05-10 | Detection layer ships with the Active Agents panel: `ProcessDetection`, `AgentClassifier` (11 agents), `ScreenHeuristics`, `PaneAgentState` + presence/stabilization, per-surface polling loop (300 ms active / 2 s idle; the PR text says 500 ms, tightened to 2 s in-branch, commit `9e31b6db`) | PR #274 | +| 2026-05-11 | Log volume trimmed to meaningful transitions only (agent lost, identity/state change); idle shells stay silent | PR #277 | +| 2026-05-13 | Claude blocked detection fixed for tall permission menus: interaction region extended to the end of the recent buffer (was `promptIndex + 11`) | PR #283 | +| 2026-05-13 | Memory leak fixed: per-tick `Task.detached` around the pure `detectState` call leaked task stacks + closures (~100–200 MB/h on release 2026.5.11, CLAW-97); call inlined | PR #285 | +| 2026-05-26 | `omx` / `oh-my-codex` recognized as Codex (alias classification, not a new agent) | PR #354 | +| 2026-05-26 | Heuristics read the *active screen* (`GHOSTTY_POINT_ACTIVE`) instead of the user-scrolled viewport, so scrollback browsing no longer corrupts state | PR #355 | +| 2026-06-13 | Working hold generalized to all agents and widened 1.2 s → 3.0 s; viewer-overlay chrome trusted only on bottom lines and mapped to `.unknown` (keep last state) | PR #438, [002](002-stability-and-scheduling.md) | +| 2026-06-13 | Oh My Pi (`omp` / `oh-my-pi`) detected as Pi, incl. its own working markers | PR #440 | +| 2026-06-13 | Lazy per-pane scheduling: `cold` (no polling) / `warm` (2 s, 30 s window after input) / `active` (300 ms) replaces UI-driven enable/disable | PR #441, [002](002-stability-and-scheduling.md) | +| 2026-06-14 | `prowl agents` CLI surfaces detection state (entry [013](../013-prowl-cli/000-plan.md)) | PR #442 | +| 2026-06-19 | Agent working/blocked folded into the worktree running indicator via `PaneAgentState.isBusy` (entry [029](../029-active-agents-panel/000-plan.md)) | PR #475 | +| 2026-06-20 | Qwen Code (`qwen`) detection: braille spinner, `esc to cancel` / `ctrl+c to cancel` | PR #483 | +| 2026-06-22 | Foreground process-group fallback for plain (non-OSC 9;4) commands merged | PR #484, [003](003-plain-command-running-indicator.md) | +| 2026-06-23 | #484 reverted on `main` (login-wrapper PID, preexec race, shell-list fragility) | commit `5b219791`, [003](003-plain-command-running-indicator.md) | +| 2026-06-26 | Ghostty reentry avoided: Active Agents entries built from cached pwd / launch working directory instead of calling `ghostty_surface_inherited_config` mid-callback (hang, issue #506) | PR #515 | +| 2026-07-12 | Native agent session identity layered onto detection (`session` fields on `PaneAgentState`) — entry [045](../045-native-agent-session-detection/000-plan.md) | PR #556 | + +## Outcome & current state (as of 2026-07-12) + +- **Domain** (`supacode/Domain/AgentDetection/`): + - `DetectedAgent.swift` — 12 agents: pi, claude, codex, gemini, cursor (`cursor-agent`), + cline, opencode, copilot, kimi, droid, amp, qwen. + - `AgentRawState.swift` — raw `working`/`blocked`/`idle`/`unknown` plus display states + (idle + unseen renders as **Done**). + - `PaneAgentState.swift` — `stabilizeAgentState` with `workingStateHold = 3.0` + (blocked bypasses the hold; `.unknown` keeps the previous state and refreshes the + hold), `AgentDetectionPresence.releaseMissThreshold = 6`, `isBusy` (working/blocked → + worktree running indicator), and — since #556 — sticky `session` retention. + - `AgentDetectionSchedule.swift` — `cold` / `warm(until:)` (30 s window) / `active`. +- **Infrastructure** (`supacode/Infrastructure/AgentDetection/`): + - `ProcessDetection.swift` — `AgentProcessProbe` actor; foreground-job snapshots cached + 0.75 s per process group. + - `AgentClassifier.swift` — argv0 / name / cmdline-token scoring, wrapper-runtime + handling; aliases `omx`/`oh-my-codex` → codex, `omp`/`oh-my-pi` → pi. + - `ScreenHeuristics.swift` — `nonisolated` pure per-agent detectors + (`detectClaude`, `detectCodex`, … `detectQwen`). + - `AgentSessionResolver.swift` / `AgentSessionProfile.swift` / `AgentPidArtifacts.swift` + / `OpenCodeSessionStore.swift` — the 045 session-identity layer, not part of this + entry's original scope. +- **Loop**: `supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift` + — one `Task` per surface driven by the schedule; intervals + `activeAgentDetectionInterval = 300 ms` / `idleAgentDetectionInterval = 2 s` in + `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`. `detectState` runs + inline on the MainActor (post-#285); process enumeration hops to the probe actor. +- **Bridge**: `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — `childPID()` + / `foregroundProcessGroupID()` wrap the fork C APIs (0 → nil); `readActiveText()` → + `readActiveContentsForCLI()` (`GHOSTTY_POINT_ACTIVE`, post-#355). +- **Running indicator**: `WorktreeTaskStatus` (idle/running) = + OSC 9;4 `progressState` per surface (`updateRunningState` / + `isRunningProgressState` in `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift`) + OR agent busy (`tabAgentBusyById` ← `PaneAgentState.isBusy`). **No** foreground-process + fallback exists in the tree — #484 is reverted (`hasRunningForegroundProcess` greps + empty). +- **User docs**: `docs/components/agent-detection.md` (agents list, state machine, + cadence) — consistent with the code above. +- **Tests**: `supacodeTests/ScreenHeuristicsTests.swift`, `AgentClassifierTests.swift`, + `PaneAgentStateTests.swift`, `AgentDetectionScheduleTests.swift`, + `ProcessDetectionSmokeTests.swift`, `DetectedAgentTests.swift`. + +## Deviations from plan + +- **Poll cadence** evolved twice: the planned always-on 300/500 ms loop shipped as + 300 ms/2 s in #274, then became lazily scheduled (#441) — cold panes are not polled at + all, so `idleAgentDetectionInterval` now only applies inside the 30 s warm window. +- **Hook-based authoritative status** (plan's Phase 3 endgame) was never built. The + successor investment went instead into session *identity* + ([045](../045-native-agent-session-detection/000-plan.md), #556) — status still comes + from screen heuristics. +- **Plain-command running detection** (#484) was merged and reverted within a day; the + capability gap is still open (issue #495; see + [003](003-plain-command-running-indicator.md)). + +## Open questions + +- Issue #495 (add `GHOSTTY_ACTION_COMMAND_STARTED` from OSC 133;C) is still open: plain + commands that emit no OSC 9;4 show no spinner unless an agent is detected. It would + require another Ghostty fork patch. +- `AgentDetectionSchedule.observedAgent(now:)` ignores its `now` parameter (cosmetic; + kept for API symmetry with `observedNoAgent`). +- `idleAgentDetectionInterval` is a slight misnomer post-#441 — it is the *warm* cadence; + truly idle (cold) panes are not polled. diff --git a/docs-ai/030-agent-status-detection/002-stability-and-scheduling.md b/docs-ai/030-agent-status-detection/002-stability-and-scheduling.md new file mode 100644 index 00000000..63f0b3f6 --- /dev/null +++ b/docs-ai/030-agent-status-detection/002-stability-and-scheduling.md @@ -0,0 +1,52 @@ +# 030 — Amendment: Status Stability & Lazy Scheduling (2026-06-13) + +## Context + +A month of live use surfaced two distinct false-idle classes and one cost problem: + +1. **Working → Done → Working flapping.** Agents blank their on-screen working cues + (spinner, "esc to interrupt") between steps, so the raw heuristic flips to idle and + back. The v1 hold was Claude-only and 1.2 s — too short for real inter-step gaps, and + Codex/Gemini/etc. had no hold at all. +2. **Viewer hints quoted in conversation pinned a working agent to Idle.** + `detectClaude` treated `ctrl+r to toggle` / `⌕ Search…` as transcript-viewer chrome + *anywhere* on screen and short-circuited to forced idle. A chat merely quoting those + strings (observed live: a conversation about these very heuristics) kept the pane + Idle for minutes — no hold duration can absorb a raw state that stays wrong. +3. **Polling cost.** Detection ran for every pane whenever the panel UI wanted it, + regardless of whether the pane had ever seen input. + +## Change + +- **#438 — stabilize status:** + - Generalized the working → idle hold to **all** detected agents + (`claudeWorkingHold` → `workingStateHold`) and widened it **1.2 s → 3.0 s**. + `blocked` bypasses the hold so permission prompts surface immediately. Accepted + trade-off: a genuine finish reports Done up to ~3 s late in exchange for no flapping. + - Viewer-chrome hint strings are trusted only on the bottom chrome lines (last 3 + non-blank); a frame showing viewer chrome now yields `.unknown` ("no signal") instead + of forced idle, and the stabilizer keeps the last trusted state + refreshes the hold + while the overlay stays open. Also fixes the flash-to-Done when opening ctrl+r/ctrl+o + during work. +- **#441 — lazy scheduling:** per-pane `AgentDetectionSchedule` replaces UI-driven + enable/disable: panes start `cold` (no polling), any input/paste/CLI write warms them + for a 30 s window at the 2 s cadence, and only a detected runtime promotes to `active` + (300 ms). Losing the agent demotes back through warm to cold, tearing the task down. + +The 3 s hold is a deliberate, kept decision: the 2026-06-12 herdr upstream review +explicitly chose not to adopt herdr's later detection refactor. + +## Refs + +- PR #438 (merged 2026-06-13) — `stabilizeAgentState`, `ScreenHeuristics` viewer-chrome + handling, `docs/components/agent-detection.md` update. +- PR #441 (merged 2026-06-13) — `supacode/Domain/AgentDetection/AgentDetectionSchedule.swift`, + wake/teardown paths in `WorktreeTerminalState+AgentDetection.swift`. + +## Current state + +Verified 2026-07-12: `workingStateHold = 3.0` and the `.unknown`-keeps-previous branch in +`supacode/Domain/AgentDetection/PaneAgentState.swift`; `AgentDetectionSchedule.warmWindow = 30`, +`activeAgentDetectionInterval = 300 ms`, `idleAgentDetectionInterval = 2 s`. Covered by +`PaneAgentStateTests` (hold parameterized over claude/codex/gemini, 3 s boundary, blocked +bypass, unknown semantics) and `AgentDetectionScheduleTests`. diff --git a/docs-ai/030-agent-status-detection/003-plain-command-running-indicator.md b/docs-ai/030-agent-status-detection/003-plain-command-running-indicator.md new file mode 100644 index 00000000..33705260 --- /dev/null +++ b/docs-ai/030-agent-status-detection/003-plain-command-running-indicator.md @@ -0,0 +1,53 @@ +# 030 — Amendment: Plain-Command Running Indicator — #484 Merge → Revert (2026-06-23) + +## Context + +The worktree/tab running indicator (`WorktreeTaskStatus`) is fed by two signals: + +- **OSC 9;4 progress reports** (`tabIsRunningById` via `updateRunningState` in + `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift`). This is + agent-*self-reported* and decoupled from terminal rendering — a tab can read idle + before the pane finishes painting. +- **Agent busy state** (`tabAgentBusyById` via `PaneAgentState.isBusy`, #475 / entry + [029](../029-active-agents-panel/000-plan.md)), added because Claude Code emits no + OSC 9;4 while it works. + +Plain commands (`sleep 60`, `npm run build`, `git clone`) emit neither signal, so they +never show the sidebar spinner. PR #484 (community, merged 2026-06-22) tried to close the +gap by enumerating the pty's foreground process group: treat "the group contains any +process other than the shell" as running, re-evaluated on title changes and +command-finished events. + +## Change + +**Reverted on `main` the next day** (commit `5b219791`, 2026-06-23) after investigation +found the approach unsound in practice: + +- `childPID()` can return a `login` wrapper PID instead of the shell PID → the group + never looks empty → infinite spinner; +- zsh `preexec` fires before `fork`, so title-change-triggered probes raced command + startup (needed a 150 ms delay hack); +- hardcoded shell-name list, zombie-process false positives, mise-shim edge cases; +- poor cost/benefit: real syscall/enumeration complexity for a cosmetic spinner on + manually typed commands. + +The recorded successor design is issue #495: patch Ghostty to fire a new +`GHOSTTY_ACTION_COMMAND_STARTED` action from OSC 133;C (shell integration already parses +it internally for `durationNs`), pairing with the existing command-finished action — +no process enumeration, no races. It would be another fork patch on top of the +[ghostty-fork-sync](../007-ghostty-embedding-integration/ghostty-fork-sync.md) branch. + +## Refs + +- PR #484 (merged 2026-06-22) — `hasRunningForegroundProcess` + extra + `updateRunningState` triggers. +- Revert commit `5b219791` (2026-06-23, direct commit on `main`, no revert PR); the full + investigation lives in the #484 PR comment thread. +- Issue #495 — OSC 133;C proposal, open as of 2026-07-12. + +## Current state + +Verified 2026-07-12: `hasRunningForegroundProcess` does not exist in the tree; +`updateRunningState` reads only `surface.bridge.state.progressState` +(`isRunningProgressState`). Plain non-OSC-9;4 commands still show no running indicator +unless an agent is detected in the pane. diff --git a/docs-ai/031-command-palette-architecture/000-plan.md b/docs-ai/031-command-palette-architecture/000-plan.md new file mode 100644 index 00000000..d7e46b5b --- /dev/null +++ b/docs-ai/031-command-palette-architecture/000-plan.md @@ -0,0 +1,107 @@ +# 031 — Command Palette Architecture: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-16 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #291, #292, #293, #294, #296, #299, #300, #302 (+ #301/#303 in-frame fixes; #218/#287 precursors; #396/#421 see Amendments) | +| **Sources** | `doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md` (absorbed here; original removed in the docs-ai migration), PR descriptions #291–#303 | +| **Related** | [002-custom-commands](../002-custom-commands/000-plan.md), [003-diff-window](../003-diff-window/000-plan.md), [012-keybinding-system](../012-keybinding-system/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md), [027-split-pane-ux](../027-split-pane-ux/000-plan.md), `docs/components/command-palette.md` | + +## Background + +The command palette (`supacode/Features/CommandPalette/`) shipped ~24 commands and +the goal was 60–80: view toggles, navigation, worktree operations, and terminal +actions were reachable only via hotkeys or menu items. Two smaller fixes had already +landed — #218 (hide contextual actions like "Change Tab Icon…" from the empty-query +list) and #287 (replace the fragile SwiftUI `@FocusState` query focus with an +AppKit-backed field after macOS 26.5 broke the Cmd+P first-responder handoff) — but +before batch-adding commands, three architectural issues would have compounded at +scale: + +1. **Confusing visibility model.** Two booleans (`isGlobal`, `isRootAction`) + collapsed into one bit of meaningful state; the 8 app-level commands set both, + so an empty Cmd+P showed a blank list in normal (no-PR) use. +2. **No keyword aliases.** The fuzzy scorer matched only `title` and `subtitle`; + "Toggle Sidebar" could not be found by typing `sb`. At 60+ commands, short + queries are load-bearing for discoverability. +3. **High cost-per-command.** Each addition touched `CommandPaletteItem.Kind`, the + builder in `CommandPaletteFeature.commandPaletteItems`, delegate routing in + `AppFeature`, and icon/badge rules in the overlay view, with no factory to + compress the repetition. + +## Goals + +- Replace the two-flag visibility model with a single explicit + `defaultSuggestion: Bool` plus a required `category` for section grouping. +- Make empty Cmd+P useful: a Recent/Suggested split capped at 8 rows, with section + headers only on empty query (typing collapses to the flat fuzzy-ranked list). +- Keyword aliases that participate in fuzzy scoring but never display; highlight + positions always come from the title so no synthetic offsets leak into the UI. +- Factories (`appShortcut`, `ghosttyCommand`) so batch additions become one-liners. +- Then batch-add commands by category: view toggles, navigation, worktree actions, + terminal/tab/pane, shelf navigation. + +**Non-goals** + +- No registry pattern — the centralized builder stays; per-feature command + contribution is a larger shift not justified at this scale. +- No frequency tracking on top of recency — the exponential-decay recency model + (7-day half-life) is good enough. +- No declarative availability framework — context conditions stay as `if` branches + in the builder. +- No list virtualization — SwiftUI `ForEach` handles ~80 rows fine on macOS 26+. + +## Design / Approach + +A four-stage PR sequence, foundations first: + +1. **PR1 — model refactor (#291), no behavior change.** Add + `Category` (`view` / `navigation` / `worktree` / `pullRequest` / `terminal` / + `app` / DEBUG-only `debug`), `keywords: [String]`, and `defaultSuggestion: Bool` + to `CommandPaletteItem`; delete `isGlobal` / `isRootAction`. A tagging table + fixed `defaultSuggestion = isGlobal && !isRootAction` per kind so empty-query + behavior stayed byte-identical. +2. **PR2 — search & empty-state UX (#292).** Recent (non-zero recency score, sorted + by score) + Suggested (`defaultSuggestion` items not in Recent, sorted by + `priorityTier` then declaration order), 8-row cap. Scorer scores `[title] + + keywords` and takes the max. Flip the 8 app-level commands to + `defaultSuggestion: true` with starter keywords (`preferences`, `update`, + `cli`, …). +3. **PR3 — factories (#293).** `CommandPaletteItem.appShortcut(...)` for + AppShortcut-backed commands and `.ghosttyCommand(_:)` for Ghostty-bridged + terminal commands (`.terminal` category, search-only, priority +100). +4. **PR4+ — batch additions.** View toggles (#294), navigation (#296), worktree + actions (#299), and a terminal/tab/pane batch that was expected to mostly pipe + through Ghostty's existing `command-palette-entry` bridge. + +**Contextuality lives in command construction**, not in the visibility flag: the +builder only constructs PR commands when an open PR exists, worktree commands when +a worktree is selected, etc. `defaultSuggestion` therefore stays a single uniform +bit — an item is suggested when the builder constructed it *and* the flag is true — +so the suggestion view is one filter + one sort with no PR-command special case. + +## Alternatives & decisions + +- **Single `defaultSuggestion` bit vs three-state enum** + (`alwaysSuggest / onSearch / contextual`): rejected the enum because + contextuality already lives in the builder; a bool is sufficient and uniform. +- **Keyword highlighting**: match positions are always computed against the title + even when a keyword scored higher, so the UI never paints highlights at indexes + that don't exist in the visible string. +- **`contextual(...)` factory** (planned third factory): deferred in #293 — it + saved one line per call site and contextual commands vary too much in shape to + share a signature. Never added since. +- **Terminal/tab/pane batch collapsed** (PR7 audit in #300): tab/pane switching and + find were dropped (Ghostty's auto-bridged `command-palette-entry` items already + cover font size, close tab/surface, new tab; ⌘F is intuitive). Shelf navigation + and bulk-selection stretch items were dropped too. Only "Repo Settings" shipped + from that batch, and a planned "New Tab" command was cut as a duplicate of + Ghostty's own entry. + +## Amendments + +- Updated 2026-06-08: post-buildout fixes — Canvas card focus routing (#396) and + first-open color-scheme flicker (#421) — see + [002-post-buildout-fixes.md](002-post-buildout-fixes.md) diff --git a/docs-ai/031-command-palette-architecture/001-action.md b/docs-ai/031-command-palette-architecture/001-action.md new file mode 100644 index 00000000..6129b085 --- /dev/null +++ b/docs-ai/031-command-palette-architecture/001-action.md @@ -0,0 +1,95 @@ +# 031 — Command Palette Architecture: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-20 | Precursor: hide contextual actions ("Change Tab Icon…", non-PR "Open Repository on Code Host") from the empty-query list; dedicated `openRepositoryOnCodeHost` Kind | PR #218 | +| 2026-05-14 | Precursor: AppKit-backed query field (`NSViewRepresentable`) replaces `@FocusState`; focus reasserted after open (macOS 26.5 Cmd+P path); terminal focus restored on dismiss, Canvas-aware via `canvasFocusedWorktreeID()` | PR #287 | +| 2026-05-16 | PR1: `category` + `keywords` + `defaultSuggestion` replace `isGlobal`/`isRootAction`; pure refactor, empty-query behavior byte-identical | PR #291 | +| 2026-05-16 | PR2: Recent/Suggested empty-query view (8-row cap, headers only on empty query), keyword aliases in fuzzy scoring, 8 app-level commands flipped to `defaultSuggestion: true`; new `CommandPaletteSuggestions` type + `suggestions(items:recencyByID:now:)` | PR #292 | +| 2026-05-16 | PR3: `appShortcut` + `ghosttyCommand` factories; `contextual(...)` factory deferred | PR #293 | +| 2026-05-17 | PR4: five view toggles (Sidebar ⌘⌃S, Active Agents ⌘⌥P, Canvas ⌘⌥↩, Shelf ⌘⇧↩, Show Diff ⌘⇧Y gated on worktree selection); `leftSidebarVisibility` lifted from `ContentView` `@State` into `AppFeature.State` | PR #294 | +| 2026-05-18 | PR5: navigation commands (Reveal in Finder, Copy Path, Reveal in Sidebar); focus-restore made default-on for all palette delegates via a deny list (`commandPaletteDelegateChangesActiveSelection`) instead of per-handler opt-in | PR #296 | +| 2026-05-18 | PR6+6.5: Run/Stop Script (state-dependent swap), Pin/Unpin, Delete Worktree, Rename Branch (via `pendingRenameBranchRequest` channel in `RepositoriesFeature`), per-repo custom commands as search-only items | PR #299 | +| 2026-05-18 | PR7 (scoped down after audit): Repo Settings command opening the Settings window at `SettingsSection.repository(repoID)`; rest of the planned terminal/tab/pane/shelf batch dropped | PR #300 | +| 2026-05-18 | Settings window: Cmd+W close shortcut + reveal selected repo row in sidebar | PR #301 | +| 2026-05-18 | Cleanup: Repo Settings handler funneled through the repositories pipeline; dead `copyPath` ⌘⇧C AppShortcut deleted (closed fork issue #295); dead `.removeWorktree`/`.archiveWorktree` Kinds deleted, delegate renamed `deleteWorktree` (−117 lines) | PR #302 | +| 2026-05-18 | Revert #301's `ScrollViewReader` auto-scroll in Settings sidebar (didn't feel right); Cmd+W close kept | PR #303 | +| 2026-06-05 | Canvas card focus routing — see [002-post-buildout-fixes.md](002-post-buildout-fixes.md) | PR #396 | +| 2026-06-08 | Color-scheme flicker fix — see [002-post-buildout-fixes.md](002-post-buildout-fixes.md) | PR #421 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/CommandPalette/CommandPaletteItem.swift` — `CommandPaletteItem` + with `category` / `keywords` / `defaultSuggestion`, the `Category` enum (six cases + + DEBUG `debug`), and `CommandPaletteSuggestions` (`maxItems = 8`, + `recent` + `suggested`, `allItems` flattener). The `Kind` enum has since grown + beyond this entry's scope: `newWorkspace` + ([042-project-workspaces](../042-project-workspaces/000-plan.md)), Canvas commands + (`expandCanvasCard`, `arrangeCanvasCards`, `organizeCanvasCards`, + `tileCanvasCards`, `selectAllCanvasCards` — + [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md) + and later canvas work), `runCustomCommand`, `debugLightDockNotificationDot`. +- `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` — the + builder `commandPaletteItems(from:customCommands:runScriptStatusByWorktreeID:actionTargetWorktreeID:ghosttyCommands:)`, + `suggestions(items:recencyByID:now:)`, `filterItems` (empty query delegates to + `suggestions(...).allItems`), `recencyRetentionIDs` pruning. +- `supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift` — the + `appShortcut` and `ghosttyCommand` factories, stable `CommandPaletteItemID` + helpers (`openRepositorySettings(_:)`, `customCommand(_:)`), + `commandPaletteRecencyScore` (7-day half-life, age capped at 30 days), and + `delegateAction(for:)` kind→delegate mapping. The planned `contextual(...)` + factory was never added (deferred in #293, no pain point since). +- `supacode/Features/CommandPalette/Reducer/CommandPaletteFuzzyScorer.swift` — + `scoreItemForPiece(label:description:keywords:query:)` scores title and each + keyword, taking the max; `doScoreFuzzy` unchanged underneath. +- `supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift` — the + AppKit `CommandPaletteQueryTextField` (`NSViewRepresentable`) from #287, + sectioned rendering (`renderSectioned` with Recent/Suggested headers on empty + query only). +- `supacode/Features/App/Reducer/AppFeature+CommandPalette.swift` — delegate + routing. The helpers named in PR bodies (`navigationDelegateAction`, + `viewDelegateAction` on the AppFeature side) were later reorganized into a + `reduceCommandPalette*Delegate` family (`Navigation`, `Repository`, `Canvas`, + `WorktreeFile`, `WorktreeAction`, `PullRequest`, `Debug`). +- `supacode/Features/App/Reducer/AppFeature+Support.swift` — + `commandPaletteDelegateChangesActiveSelection` deny list currently: + `selectWorktree`, `jumpToLatestUnread`, `viewArchivedWorktrees`, `newWorktree`, + `toggleCanvas`, `renameBranch`. Everything else gets terminal focus restored + after the delegate runs. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` — + `pendingRenameBranchRequest` channel consumed by + `supacode/Features/Repositories/Views/WorktreeDetailView.swift`, so the rename + popover's `isPresented` stays view-local. +- `AppShortcuts.copyPath` no longer exists in `supacode/App/AppShortcuts.swift` + (deleted in #302); the palette's Copy Path command (`Kind.copyPath`) remains, + without a hotkey hint. +- User-facing behavior is documented in `docs/components/command-palette.md`. + +## Deviations from plan + +- The planned PR7 (terminal/tab/pane/find) and PR8 (shelf navigation) batches were + almost entirely dropped after the #300 audit: Ghostty's auto-bridged + `command-palette-entry` items already covered font size, close tab/surface, and + new tab; tab/pane switching and find were judged unnecessary. Only Repo Settings + shipped. +- Custom commands (per-repo, user-defined) were surfaced in #299 although the plan + had not listed them; they are search-only (not default-suggested) with + UUID-stable IDs so recency survives across sessions. +- The `contextual(...)` factory from the plan's PR3 was deferred and never added. +- Rename Branch required an unplanned TCA channel (`pendingRenameBranchRequest`, + mirroring `pendingSidebarReveal`) because the popover's visibility is view-local + `@State`; it also joined the focus-restore deny list so the popover's `TextField` + keeps focus. +- #296 turned focus restore from per-handler opt-in (five sites patched in #287) + into default-on with a deny list, after PR4's new handlers silently dropped the + behavior — a structural fix the plan had not anticipated. + +## Open questions + +- PR #292 describes Recent as "anything used in the past month", but + `commandPaletteRecencyScore` caps age at 30 days and returns a positive floor + (~0.05) rather than 0, so an activated item stays in Recent indefinitely until + its ID drops out of `recencyRetentionIDs`. Behavior is stable but the + month-window description does not match the implementation. diff --git a/docs-ai/031-command-palette-architecture/002-post-buildout-fixes.md b/docs-ai/031-command-palette-architecture/002-post-buildout-fixes.md new file mode 100644 index 00000000..97c8b047 --- /dev/null +++ b/docs-ai/031-command-palette-architecture/002-post-buildout-fixes.md @@ -0,0 +1,39 @@ +# 031 — Amendment: Post-buildout fixes (Canvas focus, color-scheme flicker) + +## Context + +Two independent palette fixes landed in June 2026, after the May buildout settled. + +## Change + +**Canvas card focus (#396, 2026-06-05).** Selecting a worktree from the palette +while Canvas mode was active left Canvas and performed a Normal-mode selection. +The `selectWorktree` delegate handler now stays inside Canvas: when +`repositories.isShowingCanvas`, it routes to +`RepositoriesFeature.Action.focusCanvasWorktree` (or `focusCanvasRepository` for +plain folders) instead of `selectWorktree`. Covered by `AppFeature` tests for both +worktree and plain-folder selections in Canvas. + +**Color-scheme flicker on first open (#421, 2026-06-08).** In Light mode the +palette's first frame briefly rendered dark. Root cause: `CommandPaletteCard` +forced a color scheme computed from `NSColor.windowBackgroundColor.isLightColor` +inside `body`; that dynamic catalog color resolves against the current drawing +appearance context, which is not settled on the first render. Fix: drop the forced +override and inherit the ambient `@Environment(\.colorScheme)` (correct on first +render, reactive to appearance changes); the now-unused `isLightColor` NSColor +extension was removed. An intermediate attempt using `NSApp.effectiveAppearance` +was rejected — it reads the app/system appearance, not the window's. + +## Refs + +- PR #396 — Focus canvas cards from command palette +- PR #421 — Fix command palette color-scheme flicker on first open + +## Current state + +Both fixes verified in the tree as of 2026-07-12: the Canvas routing lives in +`reduceCommandPaletteNavigationDelegate` +(`supacode/Features/App/Reducer/AppFeature+CommandPalette.swift`), and no +`isLightColor` helper or forced card scheme remains in +`supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift` (only the +intentional selected-row dark override via `transformEnvironment(\.colorScheme)`). diff --git a/docs-ai/032-performance-hardening/000-plan.md b/docs-ai/032-performance-hardening/000-plan.md new file mode 100644 index 00000000..9a0efd65 --- /dev/null +++ b/docs-ai/032-performance-hardening/000-plan.md @@ -0,0 +1,96 @@ +# 032 — Performance Hardening: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-21 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #231, #367, #371, #398 (wave 1); #414, #415, #416, #417 (wave 2, see amendment) | +| **Sources** | PR descriptions, Sentry App Hang evidence quoted in #231, `docs-ai/017-upstream-sync-process/upstream-ledger.md` (2026-06-09 batch) | +| **Related** | [020-observability](../020-observability/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md), [030-agent-status-detection](../030-agent-status-detection/000-plan.md) | + +## Background + +This entry collects the main-thread/performance fixes that hardened Prowl once real-world +telemetry and heavy multi-agent usage exposed hot paths. It came in two waves: + +- **Wave 1 (fork-found, 2026-04-21 → 2026-06-06).** The Sentry wiring from + [020-observability](../020-observability/000-plan.md) surfaced concrete evidence: 30+ App + Hang issues on `prowl@2026.4.20` shared one breadcrumb pattern — a burst of ~50 + `repositoryPullRequestRefresh*` + ~20 `filesChanged` actions in one runloop tick, then a + 3s+ main-thread hang, with stacks bottoming out in `URL.standardizedFileURL` reached via + `RepositoriesFeature.State.isMainWorktree(_:)`. That helper was the loop body of four + sidebar-render call sites, so each view update cost `O(repos × worktrees²)` + percent-decoding on the main thread (#231). The rest of the wave was found while working + in adjacent code: a structured-concurrency timeout that could not actually interrupt + `ShellClient` processes (#371, exposed by #366's PR-refresh soft timeout — see + [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md)), deprecated FSEvents + run-loop scheduling (#367), and a SwiftUI lazy-placement cache spin in the sidebar after + collapsing/expanding long sections (#398). +- **Wave 2 (upstream ports, 2026-06-08).** The 2026-06-09 upstream review batch + ([017-upstream-sync-process](../017-upstream-sync-process/000-plan.md)) brought a set of + upstream performance fixes for paths that are near-constant in Prowl's many-agents + workload: menu-bar rebuild flicker, OSC-9 progress churn, unbounded terminal event + buffers, and split-tree re-renders (#414–#417). Documented in + [002-june-upstream-ports.md](002-june-upstream-ports.md). + +## Goals + +- Eliminate the App Hang storm: no `standardizedFileURL` work in per-render sidebar loops. +- Make structured-concurrency cancellation actually terminate `ShellClient` child + processes, so soft timeouts (#366) protect the app in production. +- Move FSEvents delivery off the deprecated run-loop scheduling API. +- Stop the sidebar's lazy-layout main-thread spin after collapse/expand of large sections. +- (Wave 2) Bound memory and main-thread churn on agent-hot paths — see amendment. + +### Non-goals + +- No general performance-metrics infrastructure; each fix was driven by a concrete + observed symptom (Sentry signature, reproducible freeze, or upstream-diagnosed churn). +- Wave-2 ports were deliberately scoped to the slices the fork needs (e.g. #417 ports only + the type-erasure removal from upstream #332, not its notification-dot rework). + +## Design / Approach + +Wave-1 fixes, each independent: + +1. **Precompute the main-worktree flag** (#231). Add `isMain: Bool` to `Worktree` + (`supacode/Domain/Worktree.swift`), computed once in `init` with a stringwise `==` + fast-path and a `standardizedFileURL` fallback as defense-in-depth (both URL fields are + already standardized at every production construction site). Reduce + `RepositoriesFeature.State.isMainWorktree(_:)` to `worktree.isMain`, making all ~10 + call sites O(1) without touching them. `isMain` is derivable from stored properties, so + `Equatable`/`Hashable` semantics are unchanged. Locked in by `WorktreeIsMainTests`. +2. **FSEvents via dispatch queue** (#367). Replace deprecated + `FSEventStreamScheduleWithRunLoop` with `FSEventStreamSetDispatchQueue`, keeping the + stream on the main queue to match the previous scheduling intent. +3. **Cancellation-aware `ShellClient`** (#371). Replace the blocking + `process.waitUntilExit()` on a detached task with an async `waitForExit(of:)` built on + `terminationHandler` + `CheckedContinuation` (double-resume guarded by a + `LockIsolated<Bool>`); wrap it in `withTaskCancellationHandler` sending SIGTERM on Task + cancel; and tear down the process from `continuation.onTermination` when the stream + consumer goes away. This makes #366's `softTimeout` effective with no changes there. +4. **Sidebar layout** (#398). Replace the sidebar's top-level `LazyVStack` with `VStack`: + after collapsing and expanding long sections, SwiftUI's lazy placement cache could spin + on the main thread while scrolling. + +## Alternatives & decisions + +- **#231 — precompute vs restructure**: consolidating the three `first(where: + isMainWorktree)` sidebar passes was considered and rejected — once each call is an O(1) + comparison the cost is negligible, and restructuring risked changing ordering semantics. + `isMainWorktree(_:)` was kept as a thin wrapper to minimize the diff. +- **#231 — monitored follow-up**: the PR planned a Sentry watch after release, with a + follow-up pass on `orderedRepositoryRoots()` / `orderedRepositoryIDs()` only if the hang + signatures persisted. No such follow-up PR exists, implying the signatures cleared. +- **#371 — fix the callee, not the caller**: rather than adding watchdog logic around + every `ShellClient` call, cancellation was wired end-to-end inside the client so all + existing consumers (notably the PR-refresh soft timeout) benefit without changes. +- **#398 — eager over lazy**: accepting eager layout of all sidebar rows was judged + cheaper than SwiftUI's misbehaving lazy placement cache for this content size. + +## Amendments + +- Updated 2026-06-08: wave 2 — four upstream-ported performance fixes for agent-hot paths + (#414–#417, from the 2026-06-09 upstream review batch) — see + [002-june-upstream-ports.md](002-june-upstream-ports.md) diff --git a/docs-ai/032-performance-hardening/001-action.md b/docs-ai/032-performance-hardening/001-action.md new file mode 100644 index 00000000..184a3ea1 --- /dev/null +++ b/docs-ai/032-performance-hardening/001-action.md @@ -0,0 +1,58 @@ +# 032 — Performance Hardening: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-21 | Cache main-worktree flag on `Worktree` to fix the Sentry App Hang storm; `isMainWorktree(_:)` becomes O(1); `WorktreeIsMainTests` added | PR #231 | +| 2026-05-29 | Propagate Task cancellation into `ShellClient` processes (async `waitForExit`, SIGTERM on cancel, teardown on stream termination); `ShellClientStreamingTests` added | PR #371 | +| 2026-05-29 | FSEvents stream scheduling moved from run loop to dispatch queue (main queue) | PR #367 | +| 2026-06-06 | Sidebar top-level `LazyVStack` → `VStack` to stop the lazy placement cache spin after collapse/expand | PR #398 | +| 2026-06-08 | Wave 2: four upstream-ported fixes for agent-hot paths (menu-bar flicker, OSC-9 coalescing, event-stream cap/coalesce, split-tree AnyView removal) | PRs #414–#417, [002-june-upstream-ports.md](002-june-upstream-ports.md) | + +## Outcome & current state (as of 2026-07-12) + +All wave-1 changes are in the current tree: + +- `supacode/Domain/Worktree.swift` stores `let isMain: Bool`, computed in `init` with the + `==` fast-path plus `standardizedFileURL` fallback (a comment documents the hot-path + rationale). `RepositoriesFeature.State.isMainWorktree(_:)` in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift` is the + thin `worktree.isMain` wrapper. Tests: `supacodeTests/WorktreeIsMainTests.swift`. +- `FSEventStreamSetDispatchQueue(stream, DispatchQueue.main)` lives in + `supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift`; at the time + of #367 this code sat in the `WorktreeInfoWatcherManager` area and was later split into + the monitors file (tests remain `supacodeTests/WorktreeInfoWatcherManagerTests.swift`). +- `supacode/Clients/Shell/ShellClient.swift` contains `waitForExit(of:)` and the + `withTaskCancellationHandler` wrapping described in the plan. Tests: + `supacodeTests/ShellClientStreamingTests.swift`. +- The sidebar fix moved files: #398 patched `SidebarView.swift`, but after the #403 file + split ([015-repositories-feature-refactor](../015-repositories-feature-refactor/000-plan.md)) + the scroll content lives in `supacode/Features/Repositories/Views/SidebarListView.swift`, + where the `VStack(spacing: 0)` carries a comment explaining why `LazyVStack` is avoided. + +Wave-2 current state is verified in [002-june-upstream-ports.md](002-june-upstream-ports.md). + +## Deviations from plan + +None known for wave 1; each fix landed as described in its PR. The #231 follow-up on +`orderedRepositoryRoots()` / `orderedRepositoryIDs()` was explicitly conditional on hang +signatures persisting and was never needed (see Open questions). + +## Open questions + +- `RepositoriesFeature.State.orderedRepositoryIDs()` + (`RepositoriesFeature+StateQueries.swift`) still calls `standardizedFileURL` once per + repository per call, the exact pattern #231 flagged for a conditional follow-up. No + follow-up PR exists — presumably the Sentry signatures cleared (App Hang tracking was + later removed entirely, see [020-observability](../020-observability/000-plan.md), so + post-removal recurrence would be invisible anyway). Cheap per-repo (not per-worktree²), + but unverified whether it ever shows up in traces today. +- The entry's anchor date (2026-05-21, from the backfill outline) postdates the first + wave-1 PR: #231 merged 2026-04-21. The outline appears to anchor the entry where the + *theme* sits chronologically; the timeline above records actual merge dates. +- The upstream ledger's 2026-06-09 batch table attributes commits slightly differently + from the PR bodies (it lists the AnyView-drop commit `db2f39d0` under the #414 row and + maps upstream #332 → #417 via `6fab2d28`, which is actually #416's merge commit). The + PR bodies are taken as authoritative here; the ledger rows look like minor bookkeeping + slips. diff --git a/docs-ai/032-performance-hardening/002-june-upstream-ports.md b/docs-ai/032-performance-hardening/002-june-upstream-ports.md new file mode 100644 index 00000000..046cb731 --- /dev/null +++ b/docs-ai/032-performance-hardening/002-june-upstream-ports.md @@ -0,0 +1,43 @@ +# 032 — Amendment: June Upstream-Ported Performance Wave (2026-06-08) + +## Context + +The 2026-06-09 upstream review batch (post-v0.10.2, see +[017-upstream-sync-process](../017-upstream-sync-process/000-plan.md) and +`docs-ai/017-upstream-sync-process/upstream-ledger.md`) included several upstream +performance fixes targeting paths that Prowl's many-parallel-agents workload makes +near-constant hot: while agents stream output, the detail view re-renders on every OSC-9 +progress tick. All four ports merged 2026-06-08 as fork-shaped implementations, not +cherry-picks. + +## Change + +| Fork PR | From upstream | Fix | +| --- | --- | --- | +| #414 | upstream #329 (FocusedAction slice) | Menu-bar commands published bare `() -> Void` closures via `focusedSceneValue`; closures are never equal, so every publisher body run rebuilt the system menu — flicker and collapsing submenus while an agent is busy. New `FocusedAction<Input>: Equatable` (`supacode/App/Models/FocusedAction.swift`) dedupes on `(isEnabled, token)`, where `token` projects captured state so commands never fire against a stale target. Fork keeps its disabled-as-`nil` convention. | +| #415 | upstream #347 | OSC-9 progress reports arrive far faster than the UI needs. `GhosttySurfaceBridge.ingestProgressReport(state:value:)` coalesces with a leading-edge-then-trailing flush (50ms throttle) plus a slow 1s/15s stale-watch replacing the per-report reset-task churn; `SET_TITLE` / `TerminalTabManager` title/dirty writes become idempotent; `GhosttySurfaceProgressBar` buckets percent to 5% steps and renders via `scaleEffect` instead of `GeometryReader` relayout. Only the bridge/tab-manager/progress-bar slices apply — the fork has no tab-stripe/shimmer. | +| #416 | upstream #376 (in spirit) | `WorktreeTerminalManager`'s unbounded `AsyncStream` grew without bound under agent tool storms (notably fork-specific `agentEntryChanged` re-emits). Cap the live stream at `.bufferingNewest(2048)` and `pendingEvents` at 1024; `TerminalEventCoalescer` drops exact-repeat "latest value wins" state events per slot, never coalescing lifecycle/notification events; dedup cache resets on resubscribe and forgets slots on `prune`. | +| #417 | upstream #332 (AnyView slice only) | `TerminalSplitTreeAXContainer` hosted `NSHostingView<AnyView>` with a fresh `AnyView` per `updateNSView`, defeating SwiftUI diffing so the whole split tree re-rendered per notification. Host the concrete `NSHostingView<TerminalSplitTreeView>` instead. Deliberately **not** ported: upstream's per-surface `@Observable` notification-dot mirror — the fork's `notifications` are observed (dot already accurate), so there was no correctness bug, and the invasive `toolbarNotificationGroups` move was skipped as regression risk without a bug behind it. | + +Tests added with the wave: `FocusedActionTests`, `GhosttySurfaceBridgeTests` +(`TestClock`-driven coalescing cases), `GhosttySurfaceProgressBarTests`, +`TerminalEventCoalescerTests`. #417 shipped without a new test (type-erasure removal, no +logic change). + +## Refs + +PRs #414, #415, #416, #417 (all merged 2026-06-08); upstream #329, #347, #376, #332; +ledger batch "2026-06-09 — Review through post-v0.10.2". + +## Current state (as of 2026-07-12) + +Verified in the working tree: + +- `supacode/App/Models/FocusedAction.swift`; `supacodeTests/FocusedActionTests.swift`. +- `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — `ingestProgressReport`; + `supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift` — `bucketedPercent`. +- `supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift`; + `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — + `eventBufferCap = 2048`, `pendingEventCap = 1024`, `.bufferingNewest` policy. +- `supacode/Features/Terminal/Views/TerminalSplitTreeView.swift` — + `TerminalSplitTreeAXContainer` holding `NSHostingView<TerminalSplitTreeView>`. diff --git a/docs-ai/033-ui-refresh-2026-05/000-plan.md b/docs-ai/033-ui-refresh-2026-05/000-plan.md new file mode 100644 index 00000000..c1497e4b --- /dev/null +++ b/docs-ai/033-ui-refresh-2026-05/000-plan.md @@ -0,0 +1,107 @@ +# 033 — UI Refresh 2026-05: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-24 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #331 + #332 (anchor); #326, #343 (same wave); #168, #169 (precursors); #467 (amendment) | +| **Sources** | PR descriptions #168/#169/#326/#331/#332/#343/#467; `feat/ui-enhancements` branch commit messages (`13fc410d..5cfad79c`); fork issues #311/#315/#317 | +| **Related** | [025-repo-identity-appearance](../025-repo-identity-appearance/000-plan.md) (repo color feeds the chrome tint; `.custom` color added here), [026-sidebar-container-refactor](../026-sidebar-container-refactor/000-plan.md) (sidebar structure this refresh built on), [023-shelf-mode](../023-shelf-mode/000-plan.md) (spine tint preferences), [031-command-palette-architecture](../031-command-palette-architecture/000-plan.md) (palette the refresh polished), [029-active-agents-panel](../029-active-agents-panel/000-plan.md) (agent rows touched by #331), `docs/components/settings.md`, `docs/reference/settings-fields.md` | + +## Background + +This is the fork's first large community contribution. In May 2026, GitHub user +**abhi21git** (Abhishek Maurya) first fixed the toolbar title hover layout shift (#326, +superseding the closed fork attempt #324), then opened #331 "Major UI Enhancements": a +single-commit, 20-file pass over the terminal tab bar, sidebar, find overlay, command +palette, Active Agents rows, and the agent loading indicator. It addressed three open +fork issues — tab visual differentiation (#311), sidebar background color mismatch +(#315), and inconsistent Shelf/Canvas mode toggling (#317). + +Two earlier fork fixes are the precursors for the "chrome tint" theme: on macOS 26, +non-opaque windows render a white-biased glass, so with Ghostty `background-opacity < 1` +the titlebar looked light even in dark mode. #168 tinted `window.backgroundColor` by +appearance (dark ⇒ black at 0.7 alpha) to counteract the bias, and #169 gave the sidebar +footer a material background under transparency. The 05 refresh generalized this ad-hoc +tinting into a deliberate window-chrome design. + +## Goals + +- Land the community refresh without losing fork identity: keep what improves the UI, + revert what regresses deliberate fork behavior, and credit the contributor. +- Terminal tab bar: clear visual differentiation of active/hovered/inactive tabs + (issue #311), floating glass look, stable layout (no hover-induced shifts). +- Sidebar: consistent background (#315), a fixed view-mode switcher top bar with + consistent Shelf/Canvas toggling (#317). +- A first-class **window chrome tint**: one setting that tints the nav band and toolbar + band across Normal / Shelf / Canvas view modes, driven by the active repo's color or + a custom color. +- Assorted polish: zero-repository empty state, settings window minimum width, + fullscreen-safe toolbar rendering (#343). + +**Non-goals** + +- No change to per-repo identity semantics ([025](../025-repo-identity-appearance/000-plan.md)) + beyond adding a free custom color; the tint *consumes* the existing repo color. + +## Design / Approach + +The distinctive process decision: instead of iterating inside the contributor's PR, the +fork took #331's commit (`13fc410d`) verbatim as the base of a review branch +`feat/ui-enhancements`, then layered 21 review/revert/extension commits on top and merged +the whole branch as #332. GitHub marked #331 merged the moment its commit landed on +`main`, so contributor attribution is preserved in history while every #331 change got +line-level review. + +On that branch, the design settled as: + +- **Tab bar**: keep #331's floating-glass direction but revert its structural reshape + (`d027ea7a`); adopt instead the adaptive brightness ladder from the fork's own closed + PR #327 — in dark mode `controlBackgroundColor` is *darker* than + `windowBackgroundColor`, so selection is conveyed by a `labelColor`-tint ladder + (bar < inactive < hovered < active), while light mode keeps the native white-tab look. + Centered titles, full-width tabs, hover close circle, close button on the leading + edge, a tab/terminal gap, and the bar tint extended across the bottom gap. +- **Window chrome tint** (`51af064e`): a `WindowTintMode` setting + (`none` / `repositoryColor` / `custom`) with unified color logic in a + `WindowChromeTint` domain enum. The tint is "driven from the detail side": the nav + band and toolbar band are overlays on full-bleed detail content, painted as one + continuous "L". Shelf chrome tints with the open book's repo color; Canvas tints the + nav only, leaving the toolbar untinted so floating cards don't sit on a colored band + (`5cfad79c`). +- **Custom repository color** (`8dc41bc3`): `.custom(TintColor)` added to + `RepositoryColorChoice` so the tint isn't limited to the fixed preset palette + (domain type owned by [025](../025-repo-identity-appearance/000-plan.md)). +- **Sidebar**: view-mode switcher reworked into a fixed top bar (`85067862`); restore + the Expand/Collapse All header controls #331 had dropped (`b47be417`); brighten the + nav picker track to match the tab bar; keep the repo color dot solid on focus loss. +- **Fork identity kept**: restore the bagua-glyph working indicator that #331 had + replaced (`b6e8d3f2`, tests fixed in `7e558ef0`). +- **Fullscreen fallback** (#343, one day later): in macOS fullscreen the AppKit toolbar + stops sampling the tinted content behind it, so an explicit toolbar background is + enabled only for fullscreen enter/steady/exit, resolved from Prowl's own Light/Dark + appearance (not the system appearance captured at launch), and held stable across + SwiftUI toolbar host detach/reattach cycles to avoid flicker. + +## Alternatives & decisions + +- **Review branch over in-PR iteration** (#331→#332): the fork rebased review commits + on top of the contributor's commit rather than requesting changes, keeping merge + attribution while enabling aggressive reverts. Both PRs merged together on + 2026-05-24. +- **Adopt #327's brightness ladder, revert #331's tab reshape** (`d027ea7a`): #327 had + been closed unmerged, but its dark-mode analysis (selection sinking into the bar) was + the accepted fix for issue #311. +- **Keep the bagua indicator** (`b6e8d3f2`): #331's replacement loading indicator was + rejected; the glyph is a deliberate fork trait. +- **Canvas toolbar stays untinted** (`5cfad79c`): a colored band behind floating cards + hurt readability, so Canvas only tints the nav. +- **Fullscreen fallback is scoped, not a rewrite** (#343): outside fullscreen the + original hidden-toolbar-background rendering path is preserved untouched; the + explicit background is a best-effort fallback only while fullscreen is involved. + +## Amendments + +- Updated 2026-06-17: toolbar icon hover fix by second community contributor + Alex-ai-future (#467) — see [002-toolbar-icon-fixes.md](002-toolbar-icon-fixes.md) diff --git a/docs-ai/033-ui-refresh-2026-05/001-action.md b/docs-ai/033-ui-refresh-2026-05/001-action.md new file mode 100644 index 00000000..8fb6ad27 --- /dev/null +++ b/docs-ai/033-ui-refresh-2026-05/001-action.md @@ -0,0 +1,89 @@ +# 033 — UI Refresh 2026-05: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-07 | Precursor: tint `window.backgroundColor` by appearance (dark ⇒ black @ 0.7 alpha) to counteract macOS 26's white-biased glass on non-opaque windows (fixes #163) | PR #168 | +| 2026-04-07 | Precursor: sidebar footer gets a `.regularMaterial` background when `background-opacity < 1` (wallpaper no longer bleeds through unblurred) | PR #169 | +| 2026-05-21 | Toolbar title hover layout shift fixed by pinning `WorktreeDetailTitleView` to `.navigation` and `ToolbarStatusView` to `.principal` (community fix by abhi21git, superseding closed fork attempt #324) | PR #326 | +| 2026-05-22 | Community "Major UI Enhancements" by abhi21git: single commit `13fc410d`, 20 files — tab bar, sidebar switcher, find-overlay redesign, command palette polish, Active Agents rows, Add Repository moved to sidebar toolbar, loading indicator replacement; fixes issues #311/#315/#317 | PR #331 | +| 2026-05-22..24 | Fork review on `feat/ui-enhancements` (21 commits on top of `13fc410d`): revert tab reshape and adopt #327 brightness ladder (`d027ea7a`); floating glass tabs, centered titles, full-width tabs, leading-edge hover close (`36a15ce9`, `76d0c0d3`, `a15ba291`); tab/terminal gap + dark-mode brightness (`3322606b`), tint across bottom gap (`41f49043`); sidebar switcher as fixed top bar (`85067862`), restore Expand/Collapse All (`b47be417`), brighten nav picker track (`09e9173c`); `WindowTintMode` chrome tint setting (`51af064e`), Shelf chrome tinted by open repo color (`64a79615`), custom (free) repository color (`8dc41bc3`), Canvas toolbar untinted (`5cfad79c`), solid repo color dot on focus loss (`6bfe1685`); restore bagua working indicator (`b6e8d3f2` + `7e558ef0`); zero-repo empty state (`18de41f2`), settings min width 800 (`eecdda08`), xcsift warnings in `build-app` (`0ca9bc9f`) | PR #332 (merges #331) | +| 2026-05-25 | Fullscreen toolbar tint: explicit toolbar background only during fullscreen enter/steady/exit, resolved from Prowl's appearance (not the launch-time system appearance), stable across toolbar host detach/reattach; regression tests | PR #343 | +| 2026-06-17 | Toolbar icon hover fix by Alex-ai-future | PR #467, [002-toolbar-icon-fixes.md](002-toolbar-icon-fixes.md) | + +Later work by the same contributors landed in other entries: Alex-ai-future's #532/#539 +(PR status polish → [028](../028-pr-status-tracking/000-plan.md)) and #540 (diff window +appearance → [003](../003-diff-window/000-plan.md)). + +## Outcome & current state (as of 2026-07-12) + +Chrome tint (the lasting architectural piece): + +- `supacode/Domain/WindowChromeTint.swift` — unified tint resolution (`Fill`, + `saturatedPeakAlpha` 0.20 / `neutralPeakAlpha` 0.10), plus the #343 fullscreen + machinery: `ToolbarFallbackEvent`, `usesExplicitToolbarBackground(isFullScreen:)`, + `toolbarFallbackState(current:event:)`, `fullscreenToolbarBackgroundComponents`, and + a `WindowFullScreenReader` observing window fullscreen state. +- `supacode/Features/Settings/Models/WindowTintMode.swift` — + `none` / `repositoryColor` / `custom`, raw-string persisted; setting UI in the + "Window Tint" section of `supacode/Features/Settings/Views/AppearanceSettingsView.swift`; + fields documented in `docs/reference/settings-fields.md` (`windowTintMode`, + `windowTintCustomColor`). +- Render sites: `supacode/Features/Repositories/Views/WorktreeDetailView.swift` + (nav/toolbar bands), `supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift`, + `supacode/Features/Shelf/Views/ShelfView.swift` / `ShelfSpineView.swift`, + `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift`. +- `supacode/Domain/RepositoryColorChoice.swift` — `case custom(TintColor)` from + `8dc41bc3` is still the free-color escape hatch (presets keep legacy encoding; see + [025](../025-repo-identity-appearance/001-action.md)). + +Tab bar: + +- `supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift` — the #327 adaptive + brightness ladder, with the dark-mode rationale preserved in comments; + `TerminalTabBarMetrics.swift` and `Views/` (`TerminalTabView.swift`, + `TerminalTabCloseButton.swift`, `TerminalTabBarBackground.swift`, ...) carry the + floating-glass look and leading-edge hover close. + +Toolbar and sidebar: + +- #326's placement fix is current: `WorktreeDetailView.swift` puts + `WorktreeDetailTitleView` in `ToolbarItem(placement: .navigation)` and + `ToolbarStatusView` in `.principal`. +- `supacode/Features/Repositories/Views/SidebarListView.swift` — fixed view-mode + switcher top bar; `EmptyStateView.swift` + `SidebarListView.swift` carry the + zero-repo empty state (`18de41f2` also forces Normal view at zero repositories). +- Settings window minimum 800×500 in + `supacode/Features/Settings/Views/SettingsView.swift` and + `SettingsWindowManager.swift`. + +Precursors' descendants: + +- #168 lives on as `GhosttyRuntime.chromeBackgroundColor(for:)` + (`supacode/Infrastructure/Ghostty/GhosttyRuntime.swift`), applied in + `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` for non-opaque windows. +- #169's `.regularMaterial` footer **no longer exists**: the refresh's sidebar/chrome + rework superseded it — `supacode/Features/Repositories/Views/SidebarFooterView.swift` + now derives its background from the `surfaceBottomChromeBackgroundOpacity` + environment value. +- #331's redesigned find overlay now lives at + `supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift`. + +Tests: `supacodeTests/WindowChromeTintTests.swift` (tint resolution + fullscreen +fallback) and `supacodeTests/BaguaWorkingIndicatorTests.swift` (post-revert indicator). + +## Deviations from plan + +- Relative to #331 as proposed, three pieces were deliberately reverted or replaced + before merge (tab bar reshape, loading indicator, Expand/Collapse All removal) — that + is the review-branch design working as intended, recorded in 000-plan. +- The #331 body's "Moved Add repository to sidebar toolbar" overlaps the earlier #254 + work; the entry point's evolution is tracked in + [026](../026-sidebar-container-refactor/002-add-repository-entry-point.md), not here. + +## Open questions + +- The backfill outline attributed the whole refresh to Alex-ai-future; GitHub records + show #326/#331 authored by **abhi21git** and only #467 by **Alex-ai-future**. This + entry follows the GitHub data. diff --git a/docs-ai/033-ui-refresh-2026-05/002-toolbar-icon-fixes.md b/docs-ai/033-ui-refresh-2026-05/002-toolbar-icon-fixes.md new file mode 100644 index 00000000..3436a193 --- /dev/null +++ b/docs-ai/033-ui-refresh-2026-05/002-toolbar-icon-fixes.md @@ -0,0 +1,34 @@ +# 033 — Amendment: Toolbar Icon Fixes (2026-06-17) + +## Context + +Three weeks after the refresh, a second community contributor, **Alex-ai-future** +(Alex), fixed two toolbar icon papercuts left over from the refreshed chrome: + +- Hovering the branch title in the toolbar showed an *additional* pencil icon next to + the branch icon, shifting the layout. +- The notification bell icon read smaller than its neighboring toolbar elements. + +## Change + +PR #467 (`fix/toolbar-icon`): + +- Branch title icon: on hover, the pencil now *replaces* the icon instead of appearing + beside it, and the icon frame is fixed at 18×18 so differently-sized SF Symbols can't + shift the layout (`supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift`). +- Bell icon: the PR initially added `.imageScale(.medium)` and tightened the HStack + spacing in `ToolbarNotificationsPopoverButton.swift`, but the author reverted that + part in-PR (`44356552`) before merge — only the branch-title fix landed. + +## Refs + +- PR #467 (merged 2026-06-17), commits `1ed489ce` (fix) + `44356552` (in-PR revert of + the bell change). + +## Current state + +`WorktreeDetailTitleView.swift` still swaps to `pencil` on hover inside a fixed +18×18 frame. `ToolbarNotificationsPopoverButton.swift` matches its pre-#467 icon +styling, as the merged PR left it. The same contributor's later PRs belong to other +entries: #532/#539 → [028](../028-pr-status-tracking/000-plan.md), #540 → +[003](../003-diff-window/000-plan.md). diff --git a/docs-ai/034-worktree-watcher-correctness/000-plan.md b/docs-ai/034-worktree-watcher-correctness/000-plan.md new file mode 100644 index 00000000..79821c7d --- /dev/null +++ b/docs-ai/034-worktree-watcher-correctness/000-plan.md @@ -0,0 +1,111 @@ +# 034 — Worktree Watcher Correctness: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-25 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #346, #373, #406, #528, #553, #555 | +| **Sources** | PR #346/#373/#406/#528/#553/#555 descriptions, fork issue #526, Sentry issue `PROWL-MACOS-D6` (via PR #528) | +| **Related** | [010-plain-folder-support](../010-plain-folder-support/000-plan.md), [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +Worktree discovery and watching depend on several path sources agreeing on identity: +`wt ls --json` (discovery), `git worktree list --porcelain` (the removal guard), +persisted repository entries, and file-system monitors keyed by URL. Prowl normalizes +paths with `standardizedFileURL`, but the sources report different forms — macOS +`/private` symlink prefixes, user-created symlinks, duplicate enumeration of the same +directory — and refresh was originally driven only by app launch, scene activation, and +a 30/60-second periodic timer. + +The resulting symptoms, collected in this entry as one correctness arc: + +- duplicate worktree rows in the sidebar (#346); +- worktree changes made outside Prowl (CLI, other tools) invisible until the next + periodic refresh (#373); +- externally-created worktrees under symlinked roots like `/tmp` impossible to delete — + the row disappeared briefly and reappeared, the directory never removed (#406); +- a Sentry-reported trap when duplicate `Worktree.ID` values reached the watcher (#528); +- `git init` inside a plain folder not upgrading the sidebar entry (#548/#553, owned by + entry 010); +- a repository added through a symbolic link loading no branch/worktree info at all + (fork issue #526 → #555). + +## Goals + +- Discovery never yields two `Worktree` values with the same identity, and downstream + collection construction is defensive rather than trapping. +- The sidebar reflects git worktree registry changes made outside Prowl without waiting + for the periodic refresh. +- Every comparison between a Prowl-tracked path and a git-reported path canonicalizes + both sides through the same transform. +- The watcher layer tolerates inconsistent input (duplicate IDs, unavailable roots) + instead of trapping or silently skipping work. +- Repositories added through symlinked paths resolve to a single canonical identity. + +### Non-goals + +- Replacing the discovery pipeline (`wt ls --json`) or the reload-everything refresh + model; fixes are targeted at identity and event-delivery correctness. + +## Design / Approach + +The anchor wave (May 2026) established the two mechanisms the later fixes build on; +each later wave is a focused amendment. + +**Discovery dedup (#346).** `GitClient.worktrees(for:)` deduplicates entries by their +standardized path (`Worktree.ID` is the standardized path string) with a first-wins +`seenWorktreeIDs` set, since `wt ls --json` can enumerate the same directory more than +once after path standardization. Defensively, the `IdentifiedArray` construction sites +at repository load and snapshot restore use `uniquingIDsWith: { current, _ in current }` +instead of the trapping `uniqueElements:` initializer. + +**Registry-driven refresh (#373).** A new `GitWorktreeRegistryMonitor` (DispatchSource +file-system-object sources, not FSEvents) watches the repository's git common directory +and its `worktrees/` registry subdirectory, resolving `.git`-file `gitdir:` and +`commondir` indirection so linked worktree checkouts map to the right registry. Events +are debounced per repository root (2 s `KeyedDebouncer`) into a +`.repositoryWorktreesChanged` watcher event, which the reducer turns into the existing +`reloadRepositories(animated: true)` path — no separate incremental-update path. The +same PR also sends the current scene phase when `ContentView` first appears, so the +active refresh loop starts even when no phase transition ever fires. + +**Later waves** (see Amendments): canonicalizing the worktree-removal guard for +`/private`-style symlinks (#406), hardening `setWorktrees` against duplicate IDs plus a +repo-wide lint ban on `Dictionary(uniqueKeysWithValues:)` (#528), plain-root upgrade +watchers (#553, detailed in +[010's amendment](../010-plain-folder-support/002-plain-upgrade-watchers.md)), and +migrating symlinked repository roots to the Git-reported canonical root (#555). + +## Alternatives & decisions + +- **Canonicalize at comparison boundaries, not in storage (#406).** Stored worktree + paths remain `standardizedFileURL`; only the removal guard maps + `git worktree list --porcelain` output through the same + `GitClient.canonicalWorktreePath(_:)` transform before comparing. +- **Two canonicalization strengths deliberately coexist.** Worktree-path matching uses + `standardizedFileURL` (resolves `/private`-style prefixes), while repository-root + identity (#555) uses the stronger `resolvingSymlinksInPath()` — and instead of + comparing loosely on every load, #555 migrates the persisted entry to the Git-reported + root once, so branch and worktree loading use one identity afterwards. +- **Preserve nested-folder behavior (#555).** Only paths that resolve to the same + file-system location as the Git root are migrated; a folder nested inside another + repository still classifies as `.plain` (entry 010's conservative upgrade rule). +- **Ban the crash class, not just the call site (#528).** Rather than patching one + `Dictionary(uniqueKeysWithValues:)` call, a custom SwiftLint rule + (`dictionary_unique_keys_with_values`, severity error) bans the initializer repo-wide + and all existing call sites were converted to explicit duplicate handling. Upstream + fixed the same crash independently (upstream #517, 2026-06-29), but the fork's + `2026.6.27` release predated that commit, so the fork shipped its own fix. +- **Event-driven refresh reuses the reload path (#373).** Registry events funnel into + the existing repository reload rather than a bespoke diffing path — simpler, at the + cost of full reloads on registry churn (bounded by the debounce). + +## Amendments + +- Updated 2026-07-12: the symlinked-roots series — worktree deletion under `/private` + symlinks (#406, 2026-06-07) and symlinked repository root resolution (#555, + 2026-07-12) — see [002-symlinked-roots.md](002-symlinked-roots.md) +- Updated 2026-06-30: duplicate worktree watcher crash (#528) — see + [003-duplicate-watcher-crash.md](003-duplicate-watcher-crash.md) diff --git a/docs-ai/034-worktree-watcher-correctness/001-action.md b/docs-ai/034-worktree-watcher-correctness/001-action.md new file mode 100644 index 00000000..f1af1684 --- /dev/null +++ b/docs-ai/034-worktree-watcher-correctness/001-action.md @@ -0,0 +1,79 @@ +# 034 — Worktree Watcher Correctness: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-25 | Deduplicate discovered worktrees: first-wins path dedup in `GitClient.worktrees(for:)`; defensive `IdentifiedArray(_, uniquingIDsWith:)` at repository load and snapshot restore; discovery + restore tests | PR #346 | +| 2026-05-30 | Refresh worktrees from git registry changes: `GitWorktreeRegistryMonitor` watching the git common dir and `worktrees/` registry, debounced `.repositoryWorktreesChanged` event → `reloadRepositories`; send initial scene phase from `ContentView.task` so the active refresh loop starts without a phase transition | PR #373 | +| 2026-06-07 | Fix deletion of externally-created worktrees under symlinked roots: `GitClient.canonicalWorktreePath(_:)` applied to both sides of the removal guard | PR #406 — see [002](002-symlinked-roots.md) | +| 2026-06-30 | Fix duplicate worktree watcher crash: first-wins dedup in `WorktreeInfoWatcherManager.setWorktrees`; SwiftLint rule banning `Dictionary(uniqueKeysWithValues:)` | PR #528 — see [003](003-duplicate-watcher-crash.md) | +| 2026-07-11 | Harden plain repository upgrade watchers (contributor PR #548 + hardening): FSEvents monitors on `.plain` roots, edge-triggered `.git` detection | PR #553 — detailed in [010's amendment](../010-plain-folder-support/002-plain-upgrade-watchers.md) | +| 2026-07-12 | Resolve symlinked repository roots: migrate persisted entries that resolve through a symlink to the Git-reported canonical root (fixes fork issue #526) | PR #555 — see [002](002-symlinked-roots.md) | + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- `supacode/Clients/Git/GitClient.swift` — `worktrees(for:)` dedups via a + `seenWorktreeIDs` set keyed on the standardized path; `canonicalWorktreePath(_:)` + normalizes to the same form `worktrees(for:)` stores; + `registeredWorktreePaths(rootPath:)` maps `git worktree list --porcelain` output + through it; the guard in `removeWorktree(_:deleteBranch:)` compares canonical paths + on both sides (and still returns silently when unmatched — see Open questions). +- `supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift` — + `GitWorktreeRegistryMonitor` (DispatchSource sources on the common git directory and + its `worktrees/` subdirectory; `GitCommonDirectory` resolves `.git`-file `gitdir:` and + `commondir` indirection), alongside `FSEventsWorktreeFileEventMonitor` and + `GitRemoteConfigMonitor`. +- `supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift` — + `setWorktrees` builds its lookup with an explicit first-wins loop and configures + watchers only for unique worktrees; `syncWorktreeRegistryMonitors` adds/cancels + per-root monitors; `repositoryWorktreesDebouncer` defaults to 2 s; plain-root + monitors and the edge-triggered `.git` marker set live here too (entry 010). +- `supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift` — events + `.repositoryWorktreesChanged(repositoryRootURL:)` and + `.plainRepositoryBecameGitRepository(URL)`. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` — both + events map to `.reloadRepositories(animated: true)`. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift` + — `upgradedRepositoryEntriesIfNeeded` migrates a persisted entry to the Git-reported + root when `pathsReferToSameFileSystemLocation(_:_:)` + (`resolvingSymlinksInPath().standardizedFileURL` on both sides) matches; repository + load builds worktree arrays with `uniquingIDsWith:`. +- `supacode/Clients/Repositories/RepositoryPersistenceClient.swift` — snapshot restore + also uses `IdentifiedArray(restoredWorktrees, uniquingIDsWith:)`. +- `.swiftlint.yml` — custom rule `dictionary_unique_keys_with_values` (severity error) + bans `Dictionary(uniqueKeysWithValues:)` repo-wide. +- `docs/components/repositories-and-worktrees.md` — documents the symbolic-link + resolution behavior added by #555. + +Regression tests exist for the two trickiest fixes: +`removeWorktreeMatchesWhenGitReportsPrivateSymlinkPath` in +`supacodeTests/GitClientRemoveWorktreeTests.swift` and +`buildsWorktreeLookupWithoutTrappingOnDuplicateID()` in +`supacodeTests/WorktreeInfoWatcherManagerTests.swift`. + +## Deviations from plan + +None known — this is a retrospective entry; each wave shipped as described in its PR. + +## Open questions + +- The `removeWorktree` guard is still a silent no-op when the tracked path is not among + the registered paths: a future path-identity mismatch class would reproduce the + #406 symptom (row disappears, then reappears) with no log line to diagnose it. +- Two canonicalization strengths coexist: `canonicalWorktreePath` relies on + `standardizedFileURL` (which resolves `/private`-style prefixes but not arbitrary + symlinks), while repository-root identity uses `resolvingSymlinksInPath()`. This is + consistent as long as `wt ls --json` and `git worktree list --porcelain` report the + same on-disk path form; a worktree reached through an arbitrary symlink that one tool + reports resolved and the other does not would still bypass the removal guard. Not + observed in practice; noted as a residual gap. +- PR #528's crash (Sentry `PROWL-MACOS-D6`) was fixed defensively; the exact + reproduction of duplicate `Worktree.ID`s reaching `setWorktrees` was not established. + Since the watcher receives `repositories.flatMap(\.worktrees)` + (`worktreesForInfoWatcher()` in `RepositoriesFeature+StateQueries.swift`), duplicates + imply cross-repository collisions — e.g. one repository persisted under two path + identities — a class that #555's entry canonicalization narrows but does not prove + eliminated. diff --git a/docs-ai/034-worktree-watcher-correctness/002-symlinked-roots.md b/docs-ai/034-worktree-watcher-correctness/002-symlinked-roots.md new file mode 100644 index 00000000..139f0724 --- /dev/null +++ b/docs-ai/034-worktree-watcher-correctness/002-symlinked-roots.md @@ -0,0 +1,58 @@ +# 034 — Amendment: Symlinked Roots Series (#406, #555) + +## Context + +Two symlink-related identity failures, a month apart, with the same underlying theme: +Prowl and git disagreeing about what a path is called. + +**#406 — undeletable external worktrees.** Worktrees created outside Prowl (by another +CLI or app) under symlinked roots like `/tmp` could not be deleted: right-click → +Delete worktree made the row disappear briefly, then reappear on the next refresh; the +directory was never removed. Root cause: `GitClient.worktrees(for:)` stores each +worktree's path via `standardizedFileURL`, which resolves the macOS `/private` symlink +(`/private/tmp/foo` → `/tmp/foo`), while the removal guard in +`removeWorktree(_:deleteBranch:)` compared that against the raw paths from +`git worktree list --porcelain`, which keep the `/private` prefix. `/tmp/foo` was never +in `{ /private/tmp/foo, … }`, so the guard silently returned and the optimistic UI +removal was undone by the next refresh. Prowl-created worktrees under `~/.prowl/…` have +no symlink components, which is why only externally-created worktrees were affected. + +**#555 — symlinked repository roots load nothing.** Fork issue #526: a user moved a +project to `/Volumes/…` and left a symlink at the original path; Prowl showed the +repository but no branch or worktree info. The persisted entry path resolved through +the symlink did not string-match the Git-reported root, so +`upgradedRepositoryEntriesIfNeeded` took the nested-inside-another-repository fallback +and classified the entry as `.plain`. + +## Change + +**#406 (merged 2026-06-07).** New `GitClient.canonicalWorktreePath(_:)` — the same +`standardizedFileURL` transform `worktrees(for:)` uses — applied to both the tracked +path and every porcelain-reported path in `registeredWorktreePaths(rootPath:)`, so the +removal guard matches regardless of `/private` prefixes. Regression test +`removeWorktreeMatchesWhenGitReportsPrivateSymlinkPath` (git reports `/private/tmp/…`, +Prowl tracks `/tmp/…`, removal now relocates and prunes). + +**#555 (merged 2026-07-12).** `pathsReferToSameFileSystemLocation(_:_:)` compares the +persisted path and the Git-reported root after +`resolvingSymlinksInPath().standardizedFileURL` on both sides. When they refer to the +same location, the persisted entry is migrated to the canonical Git root (kind `.git`), +so branch and worktree loading use one identity from then on. Paths that genuinely sit +inside another repository keep the existing `.plain` classification. The behavior is +documented in `docs/components/repositories-and-worktrees.md`. + +## Refs + +- PR #406 — "Fix deletion of externally-created worktrees under symlinked roots" +- Fork issue #526 — "[Bug] can not work when link" +- PR #555 — "Resolve symlinked repository roots"; tests in + `supacodeTests/RepositoriesFeatureTests.swift` (upgrade/downgrade group) + +## Current state + +Verified: `canonicalWorktreePath(_:)` and the canonicalized removal guard in +`supacode/Clients/Git/GitClient.swift`; `pathsReferToSameFileSystemLocation(_:_:)` and +the entry migration in +`supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift`. +Note the residual gap recorded in [001-action.md](001-action.md) Open questions: the +two fixes use different symlink-resolution strengths. diff --git a/docs-ai/034-worktree-watcher-correctness/003-duplicate-watcher-crash.md b/docs-ai/034-worktree-watcher-correctness/003-duplicate-watcher-crash.md new file mode 100644 index 00000000..e911a13f --- /dev/null +++ b/docs-ai/034-worktree-watcher-correctness/003-duplicate-watcher-crash.md @@ -0,0 +1,38 @@ +# 034 — Amendment: Duplicate Watcher Crash (#528) + +## Context + +Sentry issue `PROWL-MACOS-D6`: crashes in `WorktreeInfoWatcherManager.setWorktrees` +while building a lookup with `Dictionary(uniqueKeysWithValues:)`, which traps on +duplicate keys. The watcher receives the flat-mapped worktrees of *all* repositories +(`worktreesForInfoWatcher()`), and base-directory collisions can enumerate the same +worktree path more than once — the per-repository discovery dedup from #346 does not +cover cross-repository duplicates. + +Provenance: the trapping call was introduced upstream in `c6d14452` (2026-01-27, the +original worktree info watcher). Upstream fixed the same crash class in `84be657a` +(upstream #517, 2026-06-29), but the fork's `prowl@2026.6.27` release did not include +that commit, so the fork shipped its own fix. + +## Change + +- `setWorktrees` builds `worktreesByID` with an explicit first-wins loop and configures + watchers only for the deduplicated worktree list. +- Regression test `buildsWorktreeLookupWithoutTrappingOnDuplicateID()` in + `supacodeTests/WorktreeInfoWatcherManagerTests.swift`. +- New SwiftLint custom rule `dictionary_unique_keys_with_values` (severity error) + blocks `Dictionary(uniqueKeysWithValues:)` repo-wide; existing call sites were + converted to explicit duplicate handling (`Dictionary(_, uniquingKeysWith:)` / + `IdentifiedArray(_, uniquingIDsWith:)`), so the crash class cannot re-enter. + +## Refs + +- PR #528 — "Fix duplicate worktree watcher crash" (merged 2026-06-30) +- Sentry issue `PROWL-MACOS-D6`; upstream #517 (`84be657a`) + +## Current state + +Verified: the first-wins loop in `setWorktrees` in +`supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift`; the +`dictionary_unique_keys_with_values` rule in `.swiftlint.yml`; no +`Dictionary(uniqueKeysWithValues:)` call sites remain outside the lint rule definition. diff --git a/docs-ai/035-protected-terminal-close/000-plan.md b/docs-ai/035-protected-terminal-close/000-plan.md new file mode 100644 index 00000000..73c2f846 --- /dev/null +++ b/docs-ai/035-protected-terminal-close/000-plan.md @@ -0,0 +1,86 @@ +# 035 — Protected Terminal Close: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-25 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #345 | +| **Sources** | Fork issue #341, PR #345 description | +| **Related** | [030-agent-status-detection](../030-agent-status-detection/000-plan.md), [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/terminal.md`, `docs/components/cli.md` | + +## Background + +Fork issue #341: the user repeatedly pressed Cmd+W on a tab hosting a running +agent, believing keyboard focus was in another app, and lost in-flight agent +work with no warning. Ghostty's own `confirm-close-surface` handling was not +enough here because the thing worth protecting is not "a process is attached" +but "an agent is doing (or has just finished) work the user has not seen". +The issue asked for a second confirmation when closing a tab whose agent is +still executing. + +## Goals + +- Confirm before closing panes, tabs, or tab batches that would discard + protected terminal work. +- Protect two kinds of panes: + - an agent pane whose display state is `working`, `blocked`, or `done` + (`done` = finished but the result has not been viewed yet); + - a non-agent pane whose foreground command has been running for at least + 10 seconds (long-running builds, scripts). +- Never prompt on closes that are already safe or intentional: idle agents, + short-lived commands, surfaces whose process has exited, and internal + run-script tab replacement. + +**Non-goals** + +- Keep the protection scoped to terminal pane activity (per PR #345); no + worktree- or repository-level close guard. + +## Design / Approach + +Two layers, both landing in one PR: + +1. **Pure decision policy** — `TerminalCloseConfirmationPolicy` + (`supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift`) + maps `[TerminalCloseProtectionCandidate]` (per-pane `hasAgent`, + `agentDisplayState`, `commandRunningDuration`) to a + `TerminalCloseConfirmationDecision` (protected pane count + reason set: + `agentActive` / `longRunningCommand`). Agent presence takes priority: an + idle agent pane is never protected even if a command duration is recorded. + The long-running threshold is a policy constant (10 s), injectable for + tests. Being a pure function makes the rules unit-testable without any + terminal state. +2. **Wiring in `WorktreeTerminalState`** — every close entry point takes a + `TerminalCloseConfirmationMode` (`.prompt(target)` / `.skip`). Targets + (`.pane` / `.tab` / `.tabs(count:)`) select the `NSAlert` title and confirm + button copy; the informative text is derived from the decision's reason + set. Batch operations (Close Other Tabs / Tabs to the Right / All Tabs) + confirm once over the union of all affected surfaces, then close each tab + with `.skip` so the user is not prompted N times. Ghostty-driven surface + close requests prompt only when the child process is still alive. + +Both protection signals reuse state that already existed: + +- `surfaceAgentStates` — per-surface agent detection results from + [030-agent-status-detection](../030-agent-status-detection/000-plan.md); + the protection is a direct consumer of its `displayState` machine + (including the `seen` flag that turns `done` back into `idle`). +- `surfaceRunningStartedAtById` — new map recording when a surface's Ghostty + progress state (OSC 9;4-driven) first reported running, sampled inside the + existing `updateRunningState(for:)` pass. + +## Alternatives & decisions + +- **Scope**: PR #345 explicitly kept protection limited to terminal pane + activity rather than a broader "worktree is busy" guard. +- **`done` counts as protected**: the confirmation protects unseen completed + results, not only running work — closing right as the agent finishes is the + same accident the issue describes. +- **Batch confirm-once**: one aggregate prompt for multi-tab operations + instead of per-tab prompts; individual closes inside the batch use `.skip`. + +## Amendments + +None. Later refactors that moved this code are recorded in +[001-action.md](001-action.md). diff --git a/docs-ai/035-protected-terminal-close/001-action.md b/docs-ai/035-protected-terminal-close/001-action.md new file mode 100644 index 00000000..c3f3fa47 --- /dev/null +++ b/docs-ai/035-protected-terminal-close/001-action.md @@ -0,0 +1,60 @@ +# 035 — Protected Terminal Close: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-25 | Confirmation policy + wiring for pane, tab, and tab-batch closes; run-script tab replacement and dead-process closes skip the prompt; policy unit tests | PR #345 (fork issue #341) | +| 2026-06-07 | Confirmation flow moved into `WorktreeTerminalState+Surfaces.swift` during the large-file split; mode/target enums widened from `private` to internal | PR #403 | +| 2026-06-07 | CLI tab/pane close commands route through the same confirmation; `--force` maps to `.skip` | PR #405 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift` — + `TerminalCloseProtectionCandidate`, `TerminalCloseProtectionReason` + (`agentActive` / `longRunningCommand`), `TerminalCloseConfirmationDecision`, + and the pure `decision(for:threshold:)` with + `longRunningCommandThreshold = 10` seconds. Agent panes are protected while + `agentDisplayState` is `.working` / `.blocked` / `.done`; `.idle` (and + agent-less panes under the threshold) are not. +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — declares + `TerminalCloseConfirmationMode` (`.prompt(target)` / `.skip`) and + `TerminalCloseConfirmationTarget` (`.pane` / `.tab` / `.tabs(count:)` with + alert copy); `closeTab(_:confirmation:)` guards on the confirmation, and + `closeOtherTabs` / `closeTabsToRight` / `closeAllTabs` confirm once with + `.prompt(.tabs(count:))` then close each tab with `.skip`. Run-script tab + replacement (`runScript` / `stopRunScript`) closes with `.skip`. +- `supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift` — + `confirmCloseIfNeeded(tabIds:/surfaceIDs:mode:)`, + `closeProtectionCandidates(surfaceIDs:)`, the `NSAlert`-based + `presentCloseConfirmation` (synchronous `runModal`), and + `closeConfirmationMessage(for:)`. `closeSurface(id:confirmation:)` defaults + to `.prompt(.pane)`; `handleCloseRequest(for:processAlive:)` prompts only + when the process is alive. `updateRunningState(for:)` maintains + `surfaceRunningStartedAtById` from each surface's Ghostty progress state. +- Agent signal: `surfaceAgentStates: [UUID: PaneAgentState]` + (`supacode/Domain/AgentDetection/PaneAgentState.swift`); `displayState` + maps raw `idle` to `.done` while `seen == false`, so "unseen result" + protection ends exactly when the pane is viewed + (see [030-agent-status-detection](../030-agent-status-detection/000-plan.md)). +- CLI integration: the `closeTab` / `closePane` handlers in + `supacode/App/supacodeApp.swift` pass `force ? .skip : .prompt(...)`, so + `prowl close-tab` / `close-pane` hit the same GUI prompt unless `--force` + is given — documented in `docs/components/cli.md`. +- Tests: `supacodeTests/TerminalCloseConfirmationPolicyTests.swift` — four + Swift Testing cases covering working/blocked/done protection, idle + exemption, the 10 s threshold boundary, and pane counting across a tab. + +## Deviations from plan + +None known. PR #345's structure survived intact; PR #403 only relocated the +confirmation helpers from `WorktreeTerminalState.swift` into the `+Surfaces` +extension file, and PR #405 extended the existing mode parameter to CLI +callers rather than adding a parallel path. + +## Open questions + +- `closeConfirmationMessage(for:)` hardcodes "at least 10 seconds" in the + alert text while the policy's threshold is a parameter (defaulted to the + same constant). If the threshold were ever tuned, the copy would silently + drift. Cosmetic only today since all call sites use the default. diff --git a/docs-ai/036-window-management-hardening/000-plan.md b/docs-ai/036-window-management-hardening/000-plan.md new file mode 100644 index 00000000..f8ab8373 --- /dev/null +++ b/docs-ai/036-window-management-hardening/000-plan.md @@ -0,0 +1,103 @@ +# 036 — Window Management Hardening: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-26 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #261, #353, #381, #428, #431 (amendments: #494, #523) | +| **Sources** | PR descriptions #261/#353/#381/#428/#431/#494/#523; fork issues #297, #490; upstream review ledger → `docs-ai/017-upstream-sync-process/upstream-ledger.md` | +| **Related** | [013-prowl-cli](../013-prowl-cli/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), [020-observability](../020-observability/000-plan.md), [035-protected-terminal-close](../035-protected-terminal-close/000-plan.md) | + +## Background + +Prowl's main window is a singleton SwiftUI `Window(_:id:)` scene. SwiftUI tears the +backing `NSWindow` down when the window closes, and on a macOS restart-relaunch +(loginwindow "reopen windows at login") the scene is often not recreated at all — the +process launches with zero windows. A bare `NSApp.activate(ignoringOtherApps:)` cannot +bring back a torn-down scene; only `openWindow(id:)` rebuilds it, and before this work +that only happened implicitly when `applicationShouldHandleReopen` fired (Dock icon +click). Activation paths that do not trigger reopen — Cmd-Tab, Mission Control, CLI +`prowl open`, menu commands — left the app stuck windowless and apparently unresponsive. + +Fork issue #297 reported exactly this: Prowl unresponsive after a macOS restart with +window restoration, and intermittently unresponsive when re-activating after all windows +had been closed. A stackshot existed but did not conclusively prove the trigger path, so +the effort was framed as a targeted mitigation plus field observability rather than a +proven root-cause fix. + +Groundwork predating the anchor: PR #261 (2026-05-09, adaptation of upstream #297/#298 +from the post-v0.8.5 review batch) added dynamic main-window titles, centralized +main-window surfacing so app reopen / CLI open / Window menu / quit confirmation all +target the real main window instead of Settings or panels, and routed quit termination +through an injectable `AppLifecycleClient` so confirmation behavior is testable. + +## Goals + +- Recreate the main window from **every** activation path, including relaunches that + start with zero windows, by bridging AppKit lifecycle code to SwiftUI's + `openWindow(id:)`. +- Identify the main window strictly (visible window with identifier `WindowID.main`) + instead of guessing among AppKit helper windows. +- Ship diagnostics (structured logs + narrow Sentry events) that can confirm or refute + the mitigation in the field, and keep those diagnostics free of false positives + (inactive/background launches, stale windowless state). +- Keep quit confirmation anchored to the real main window. + +### Non-goals + +- Proving the exact root cause of issue #297 (PR #381 self-assessed 65/100 confidence; + observability was the compensating investment). +- Auxiliary window behavior (Diff/Settings/Debug) — handled later as amendments. + +## Design / Approach + +- **Window identity** — `WindowID.main` (`supacode/App/WindowSurfacing.swift`) is stamped + onto the main `NSWindow` by `WindowTabbingDisabler` + (`supacode/App/WindowTabbingDisabler.swift`), which also disables native tabbing and + persists the window frame. +- **Opener bridge** — `MainWindowOpener` (`supacode/App/MainWindowOpener.swift`) holds a + registered `openWindow(id: WindowID.main)` closure. It is registered from two places: + the app command tree (`supacode/Commands/WindowCommands.swift`), which is built even + when no window exists — essential for zero-window relaunches — and the main window + content (`registersMainWindowOpener()`), which refreshes registration on appearance. +- **Surfacing** — `NSApplication.surfaceMainWindow()` finds a main-window candidate and + deminiaturizes/orders it front; when no candidate exists it requests a new window via + the registered opener, falling back to activation-only when no opener is registered + yet. All callers (app delegate reopen/activation hooks, Window menu, CLI open handler, + quit flow) go through this one entry point. +- **Diagnostics** — `WindowLifecycleDiagnostics` (same file) tracks windowless periods, + logs via `SupaLogger`, runs a main-thread heartbeat, and captures two narrow Sentry + event kinds in release builds: `main_window_timeout` (windowless ≥ 10 s) and + `windowless_main_thread_stall` (heartbeat lag ≥ 5 s while windowless), tagged with + main/visible window counts and opener registration state. +- **Quit** — `AppFeature.requestQuit` surfaces the main window before presenting the + confirmation alert; termination goes through `AppLifecycleClient` + (`supacode/Clients/AppLifecycle/AppLifecycleClient.swift`). + +## Alternatives & decisions + +- **Disk lifecycle logging vs SupaLogger + Sentry** — #353 replaced temporary on-disk + lifecycle logs with SupaLogger-only diagnostics plus narrow Sentry events; disk logging + was a debugging scaffold, not a shippable mechanism. +- **Helper-window fallback removed** — #353 initially allowed surfacing arbitrary + non-panel AppKit windows when no identified main window was found. Sentry evidence + showed helper windows being counted as the main window, so #381 tightened the rule to + "visible `WindowID.main` only" and deleted the fallback. +- **Diagnostics ordering** — #381 moved the windowless note before `openWindow(id:)` to + stop stale windowless reports racing a successfully recreated window. +- **Report only when actionable** — #428/#431 decided that stall/timeout Sentry reports + are only meaningful for active sessions with genuinely no visible main window; both + paths re-check window state at report time and resolve stale tracking instead of + reporting (driven by Sentry issues PROWL-MACOS-AW / PROWL-MACOS-AV being dominated by + background-launch and stale-state samples). +- **Mitigation over closure** — the team explicitly accepted a mitigation-plus-telemetry + posture (#381: "targeted mitigation plus better observability rather than a fully + proven root-cause closure"). + +## Amendments + +- Updated 2026-06-23: auxiliary windows (Diff/Settings/Debug) invisible in fullscreen + Spaces — see [002-fullscreen-auxiliary-windows.md](002-fullscreen-auxiliary-windows.md) +- Updated 2026-06-28: Close Window in fullscreen no longer hides the window (black-screen + fix) — see [003-fullscreen-close-black-screen.md](003-fullscreen-close-black-screen.md) diff --git a/docs-ai/036-window-management-hardening/001-action.md b/docs-ai/036-window-management-hardening/001-action.md new file mode 100644 index 00000000..6b3bf626 --- /dev/null +++ b/docs-ai/036-window-management-hardening/001-action.md @@ -0,0 +1,71 @@ +# 036 — Window Management Hardening: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-09 | Dynamic main-window titles (repository/worktree/canvas/archive/tab); centralized main-window surfacing for reopen, CLI open, Window menu, and quit confirmation; quit routed through injectable `AppLifecycleClient` (adaptation of upstream #297/#298) | #261 | +| 2026-05-26 | `MainWindowOpener` registers SwiftUI `openWindow(id:)` from the command tree; no-window `surfaceMainWindow()` goes through the opener before falling back to activation; SupaLogger-only diagnostics with narrow Sentry events for windowless timeouts/stalls (fixes fork issue #297) | #353 | +| 2026-06-03 | Only a visible `WindowID.main` window counts as the main window; helper-window fallback removed; windowless note moved before `openWindow(id:)` to avoid stale reports; Sentry tags for main/visible window counts; snapshot-based surfacing tests | #381 | +| 2026-06-09 | Windowless main-thread-stall reports suppressed while the app is inactive; visible-main-window recheck at report time resolves stale windowless tracking (Sentry PROWL-MACOS-AW) | #428 | +| 2026-06-09 | Same recheck applied to `main_window_timeout` reports; inactive launch suppression kept (Sentry PROWL-MACOS-AV) | #431 | +| 2026-06-23 | Auxiliary windows join the active (fullscreen) Space — see [002-fullscreen-auxiliary-windows.md](002-fullscreen-auxiliary-windows.md) | #494 | +| 2026-06-28 | Fullscreen Close Window no longer hides the main window — see [003-fullscreen-close-black-screen.md](003-fullscreen-close-black-screen.md) | #523 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/App/WindowSurfacing.swift` holds the whole surfacing/diagnostics stack: + - `WindowID` (`main`, `settings`) window identifiers. + - `NSApplication.surfaceMainWindow()` — deminiaturize/order-front an existing + candidate, else request recreation via `MainWindowOpener.shared.openMainWindow()`, + else activate-only. + - `MainWindowSurface` — pure snapshot helpers (`hasVisibleMainWindow`, + `mainWindowCandidate`, window counts) so the #381 rules are unit-testable; a main + window is strictly `identifier == WindowID.main`. + - `WindowLifecycleDiagnostics` — windowless tracking with 5 s log reminders, a 1 s + main-thread heartbeat (0.3 s stall threshold), and release-only Sentry events + `main_window_timeout` (≥ 10 s) and `windowless_main_thread_stall` (lag ≥ 5 s), + fingerprinted `["prowl", "main-window-surfacing", kind]`. The #428/#431 logic lives + in `windowlessStallReportDecision` / `windowlessTimeoutReportDecision` returning + `.report` / `.suppress` / `.resolveVisibleMainWindow`. A DEBUG-only launch stall can + be injected via the `ProwlDebugLaunchStallSeconds` default. +- `supacode/App/MainWindowOpener.swift` — opener singleton; registered from + `supacode/Commands/WindowCommands.swift` (command tree, works with zero windows) and + from the main window content via `registersMainWindowOpener()` in + `supacode/App/supacodeApp.swift`. +- `supacode/App/supacodeApp.swift` (app delegate) — starts the heartbeat and notes + `launch` windowless state on launch; `applicationDidBecomeActive` and + `applicationShouldHandleReopen` surface the main window when no visible main window + exists; `applicationShouldTerminateAfterLastWindowClosed` returns `false`. +- `supacode/App/WindowTabbingDisabler.swift` — stamps `WindowID.main`, sets the frame + autosave name, disables tabbing, and implements the close policy (amended by #523). +- `supacode/App/WindowTitle.swift` — title computation from `RepositoriesFeature.State` + selection plus the selected terminal tab, with control-character sanitization. +- `supacode/Clients/AppLifecycle/AppLifecycleClient.swift` — `surfaceMainWindow` / + `terminate` dependency; `AppFeature.requestQuit` + (`supacode/Features/App/Reducer/AppFeature.swift`) surfaces the main window before the + confirm-quit alert and honors the `confirmBeforeQuit` setting. +- CLI path: `supacode/CLIService/OpenCommandHandler.swift` calls + `surfaceMainWindow()` when handling `prowl open`. +- Tests: `supacodeTests/WindowSurfacingTests.swift`, + `supacodeTests/MainWindowOpenerTests.swift`, `supacodeTests/WindowTitleTests.swift`, + `supacodeTests/AppFeatureQuitTests.swift`, + `supacodeTests/WindowTabbingDisablerTests.swift`. + +## Deviations from plan + +- #353's original candidate search accepted non-panel helper windows as a fallback; #381 + reversed that within the same effort after Sentry evidence. Recorded as an in-frame + correction, not a separate entry. +- Otherwise none known; #428/#431 narrowed diagnostics as planned once field data showed + false positives. + +## Open questions + +- Root cause of fork issue #297 was never fully proven; #381 rated its own fix 65/100 + for the failure mode. Whether the Sentry event kinds have gone quiet since #428/#431 + cannot be verified from the repository. +- `WindowLifecycleDiagnostics.noteWindowless` is still called unconditionally from + `surfaceMainWindow` when an opener is registered, relying on later rechecks + (`noteMainWindowAppeared`, report-time decisions) to clear stale state — correct today + but easy to regress if a new caller forgets the resolution path. diff --git a/docs-ai/036-window-management-hardening/002-fullscreen-auxiliary-windows.md b/docs-ai/036-window-management-hardening/002-fullscreen-auxiliary-windows.md new file mode 100644 index 00000000..e0e11790 --- /dev/null +++ b/docs-ai/036-window-management-hardening/002-fullscreen-auxiliary-windows.md @@ -0,0 +1,29 @@ +# 036 — Amendment: Auxiliary Windows Invisible in Fullscreen + +## Context + +With the main window in native fullscreen (an exclusive macOS Space), opening an +auxiliary window — Diff, Settings, or Debug — created the `NSWindow` on a background +Space instead of the fullscreen Space. The window existed but never appeared, so +clicking the corresponding buttons looked like a no-op. Root cause: windows configured +only with `tabbingMode = .disallowed` do not automatically join the active Space. + +## Change + +Set `collectionBehavior = [.moveToActiveSpace]` on the windows created by all three +auxiliary window managers so macOS moves them onto the current (including fullscreen) +Space: + +- `supacode/Features/DiffView/DiffWindowManager.swift` +- `supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift` +- `supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift` + +## Refs + +- PR #494 (merged 2026-06-23) +- Related: [003-diff-window](../003-diff-window/000-plan.md) (Diff window manager) + +## Current state + +All three managers still set `[.moveToActiveSpace]` at window creation (verified +2026-07-12 in the files above). diff --git a/docs-ai/036-window-management-hardening/003-fullscreen-close-black-screen.md b/docs-ai/036-window-management-hardening/003-fullscreen-close-black-screen.md new file mode 100644 index 00000000..c8f774f4 --- /dev/null +++ b/docs-ai/036-window-management-hardening/003-fullscreen-close-black-screen.md @@ -0,0 +1,32 @@ +# 036 — Amendment: Fullscreen Close-Window Black Screen + +## Context + +Outside fullscreen, Close Window intentionally hides the main window instead of closing +it: `WindowTabbingDisabler`'s window delegate intercepts `windowShouldClose`, calls +`orderOut(nil)`, and returns `false`, so the SwiftUI scene survives and can be +resurfaced. In fullscreen this policy backfired: once the last terminal tab was closed, +another `Cmd+W` fell through from tab-close to the window-close command, and +`orderOut(nil)` on a fullscreen window left the re-shown Ghostty area black (fork issue +#490 describes the black screen where `EmptyTerminalPaneView` should appear). + +## Change + +Make the hide-on-close policy conditional on fullscreen state: +`WindowTabbingDisabler.WindowTabbingView.windowShouldClose` only calls `orderOut(nil)` +when `shouldOrderOutOnClose(styleMask:)` is true, i.e. when the window's style mask does +not contain `.fullScreen`. The non-fullscreen hide behavior is unchanged, and the +delegate still returns `false` so the window is never actually closed. Focused tests +cover the fullscreen vs non-fullscreen policy. + +## Refs + +- PR #523 (merged 2026-06-28), fixes fork issue #490 +- Related: [035-protected-terminal-close](../035-protected-terminal-close/000-plan.md) + (the `Cmd+W` tab-close path this falls through from) + +## Current state + +`supacode/App/WindowTabbingDisabler.swift` implements +`shouldOrderOutOnClose(styleMask:)` as `!styleMask.contains(.fullScreen)`; tests in +`supacodeTests/WindowTabbingDisablerTests.swift` (verified 2026-07-12). diff --git a/docs-ai/037-line-diff-tracking/000-plan.md b/docs-ai/037-line-diff-tracking/000-plan.md new file mode 100644 index 00000000..76aed5fd --- /dev/null +++ b/docs-ai/037-line-diff-tracking/000-plan.md @@ -0,0 +1,91 @@ +# 037 — Line-Diff Tracking: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-05-28 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #365 (event-driven refresh), #377 (per-repo toggles); #491 (adaptive debounce + untracked lines); #508/#511 (deferred-refresh fix); #298 (badge truncation) | +| **Sources** | `doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md` (absorbed here; original removed in the docs-ai migration), PR descriptions listed above, fork issues #364 and #488 | +| **Related** | [003-diff-window](../003-diff-window/000-plan.md) (the diff *viewer* the badge links to; also home of the `KeyedDebouncer` extraction), [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md) (sibling sidebar pipeline; #377 also gates its refresh), `docs/components/diff-view.md` | + +## Background + +Every worktree row in the sidebar shows a `+N/-M` line-change badge (clicking it opens +the diff window, see [003-diff-window](../003-diff-window/000-plan.md)). The badge is +computed by `GitClient.lineChanges(at:)` running `git diff HEAD --shortstat` per +worktree, orchestrated by `WorktreeInfoWatcherManager`. + +The inherited model was fixed-cadence polling: `git diff HEAD --shortstat` ran on **all** +worktrees at a fixed interval regardless of whether anything had changed. A user report +(fork issue #364) showed this creates sustained background CPU/IO in very large +repositories — `--shortstat` still computes line-level diffs, and its cost scales with +the number of dirty files (benchmarks in the 2026-06-22 plan doc measured ~2.0 s per +invocation for 20 000 dirty files). + +## Goals + +- Stop asking git for line diffs when nothing can have changed: refresh on *events* + (file-system activity, HEAD changes, app foreground), not on a timer. +- Keep git as the single source of truth for counts; file-system events are only an + invalidation signal, never a diff computation. +- Give per-repository escape hatches for repos where even reduced background work is + unwanted (line-diff observation and PR-state fetching independently toggleable). +- Later wave (#491): make the badge feel instant on normal-sized repos without giving up + the conservative behavior on huge ones, and stop ignoring untracked files. + +**Non-goals** + +- Exposing polling/debounce intervals as user settings — #365 explicitly chose reducing + scheduled work over configurable cadence, and #491's adaptive tiers keep that stance. +- Changing PR polling — already batched to one GraphQL call per host + ([028-pr-status-tracking](../028-pr-status-tracking/000-plan.md), #366). +- Replacing `git diff HEAD --shortstat` with `git status --porcelain`: the badge needs + exact line counts, porcelain gives file counts only (2026-06-22 plan doc decision). + +## Design / Approach + +The anchor wave (#365) replaced polling with an activity-gated, event-driven model in +`WorktreeInfoWatcherManager`: + +- **Active set**: a worktree is line-diff *active* iff it is selected or has an open + terminal tab. `AppFeature` forwards tab lifecycle (`tabCreated` / last `tabClosed`) + into the watcher via a new `setOpenedWorktreeIDs` command. +- **FSEvents invalidation**: active worktrees get a root-level FSEvents stream whose + events are debounced (30 s at the time) before emitting `.filesChanged`, which drives + the existing `GitClient.lineChanges` path in the reducer. +- **Safety refresh**: active worktrees keep a slow 300 s repeating refresh as a fallback + for missed/coalesced FSEvents and sleep/wake; inactive worktrees get none. +- **One-shot refreshes preserved**: initial load refreshes immediately; worktrees added + later get one deferred, phase-offset refresh (tracked in `deferredLineChangeIDs`) and + then stay quiet; app foreground triggers a one-shot pass (`refreshLineChanges`). +- **HEAD watcher**: commits/branch switches fire a separate, faster debounce path + (`scheduleFilesChanged`). + +The follow-up (#377) added two per-repository settings, both default-on, decoded without +a schema bump (`Bool?`, `nil` ⇒ enabled): **Observe line diffs automatically** and +**Fetch pull request state**. Gating happens at the point the work would run — the +`.filesChanged` handler short-circuits before `gitClient.lineChanges`, and the PR +refresh choke point short-circuits before enqueueing. + +The June wave (adaptive tiers + untracked lines, planned in the absorbed 2026-06-22 doc) +is described in [002-adaptive-debounce-and-untracked-lines.md](002-adaptive-debounce-and-untracked-lines.md). + +## Alternatives & decisions + +- **Event-driven reduction over interval settings** (#365): the #364 discussion + considered exposing polling intervals; instead Prowl reduces how much work gets + scheduled at all. FSEvents deliberately never computes diffs — worst failure mode is a + delayed badge, never a wrong count. +- **Per-repo on/off over configurable cadence** (#377): landed in the #364 discussion as + simpler and more useful; the PR toggle exists for API rate-limit budget rather than CPU. +- **One-size debounce cannot serve all repos** (#488 → #491): the 30 s FSEvents debounce + chosen for worst-case repos made badges feel stuck on normal ones; resolved by sizing + debounce per repository from the git index entry count instead of a global constant. + +## Amendments + +- Updated 2026-06-22: adaptive per-repo debounce tiers + untracked lines counted in the + badge (#491) — see [002-adaptive-debounce-and-untracked-lines.md](002-adaptive-debounce-and-untracked-lines.md) +- Updated 2026-06-25: badge stuck after commit; HEAD watcher events were swallowed by the + deferred gate (#508, #511) — see [003-deferred-refresh-after-commit.md](003-deferred-refresh-after-commit.md) diff --git a/docs-ai/037-line-diff-tracking/001-action.md b/docs-ai/037-line-diff-tracking/001-action.md new file mode 100644 index 00000000..015ef9c8 --- /dev/null +++ b/docs-ai/037-line-diff-tracking/001-action.md @@ -0,0 +1,78 @@ +# 037 — Line-Diff Tracking: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-05-18 | Sidebar diff badge truncation fix: `.fixedSize(horizontal: true, vertical: false)` on the change-count view so long `+N/-M` values don't clip (community PR by Norvon) | PR #298 | +| 2026-05-28 | Event-driven line-diff refresh replaces fixed-cadence polling: opened/selected worktrees get FSEvents invalidation (30 s debounce) + 300 s safety refresh; inactive worktrees only get one-shot initial/deferred/foreground refreshes; `setOpenedWorktreeIDs` + `refreshLineChanges` watcher commands | PR #365 | +| 2026-05-30 | Per-repository "Observe line diffs automatically" and "Fetch pull request state" toggles (default on, no schema bump); reducer-level gating at the work sites | PR #377 | +| 2026-06-22 | Adaptive per-repo debounce tiers from git index entry count (1–2 s small / 2–5 s medium / 5–15 s large); untracked file lines folded into the `+N` count | PR #491, [002](002-adaptive-debounce-and-untracked-lines.md) | +| 2026-06-25 | Fix badge not refreshing for up to 5 min after commit (`deferredLineChangeIDs` gate swallowed HEAD watcher events); deterministic HEAD watcher test seam | PR #508, #511, [003](003-deferred-refresh-after-commit.md) | + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- `supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift` — + the orchestrator. `isLineChangesActive(_:)` = selected ∪ `openedWorktreeIDs`; active + worktrees run an `FSEventsWorktreeFileEventMonitor` plus a 300 s safety refresh + (`lineChangesSafetyRefreshInterval`). `LineChangesTiming` nests the tier table + (`small`/`medium`/`large`, `tier(forIndexEntryCount:)` with `<5_000` / `<20_000` + boundaries); per-repo tiers are cached in `repositoryLineChangesTimings` keyed by + standardized repository root, populated by `refreshRepositoryTimings(for:)` via + `indexEntryCountProvider` (default `GitClient.indexEntryCount(at:)`). + `scheduleFilesChanged` (HEAD path) routes through `scheduleLineChangesRefresh` so + `emitLineChangesChanged` clears `deferredLineChangeIDs` before emitting (the #508 fix, + documented in an inline comment). Event debouncing uses `KeyedDebouncer` from + `supacode/Support/Debouncer.swift` (extracted in #537, see + [003-diff-window](../003-diff-window/003-render-pipeline-hardening.md)). +- `supacode/Clients/Git/GitClient.swift` — `lineChanges(at:)` runs + `git diff HEAD --shortstat` and `git ls-files --others --exclude-standard -z` + (with `core.quotePath=false`) concurrently via `async let`, adds + `countLinesInFiles(_:relativeTo:)` output to the tracked `added` count, and skips + entirely while the index is locked (`isWorktreeIndexLocked`, an upstream-era guard). + `indexEntryCount(at:)` reads the 12-byte index header, validating the `DIRC` magic and + version 2–4 before decoding the big-endian entry count. `countLines(in:)` streams in + 64 KB chunks, treats a NUL in the first 8 KB as binary (skip), and counts trailing + unterminated lines. +- `supacode/Features/Settings/Models/RepositorySettings.swift` — optional overrides + `observeLineDiffsAutomatically` / `fetchPullRequestState` with resolved accessors + `observesLineDiffsAutomatically` / `fetchesPullRequestState` (default `true`). +- Gating sites: `.filesChanged` handler in + `supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift` guards + on `observesLineDiffsAutomatically` before calling `gitClient.lineChanges`; the PR + counterpart guards in `supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift`. +- `supacode/Features/Settings/Views/RepositorySettingsView.swift` — both toggles live in + the "Diffs & Pull Requests" section. +- `supacode/Features/Repositories/Views/WorktreeRow.swift` — + `WorktreeRowChangeCountView` renders the badge with the #298 `.fixedSize` fix in place. +- `supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift` — the #511 + seam: `WorktreeHeadEventMonitoring` protocol with the production + `DispatchSourceWorktreeHeadEventMonitor`, alongside `WorktreeFileEventMonitoring`. +- Tests: `supacodeTests/WorktreeInfoWatcherManagerTests.swift` (activity gating, + staggered deferred refresh, tier selection, HEAD-watcher-vs-deferred regression), + `supacodeTests/GitClientLineChangesTests.swift` (untracked lines, binary skip, index + header), `supacodeTests/RepositorySettingsKeyTests.swift` and + `supacodeTests/RepositoriesFeatureTests.swift` (toggle defaults and gating). + +## Deviations from plan + +- The 2026-06-22 plan sketched a free function `lineChangesTimingTier(forFileCount:)` + returning a `LineChangesTimingTier` struct; implemented instead as + `LineChangesTiming.tier(forIndexEntryCount:)` nested in the watcher manager. Interval + values match the plan exactly. +- The plan's `indexEntryCount` sketch read the count without validation; the + implementation additionally checks the `DIRC` signature and index version 2–4. +- The plan proposed refreshing the per-repo file-count cache "on `setWorktrees` or once + per app-foreground cycle"; the implementation populates only from `setWorktrees` + (missing roots only) and drops obsolete roots — there is no foreground re-read, so a + repo's tier is effectively fixed for the app run once computed. + +## Open questions + +- `repositoryLineChangesTimings` never re-tiers an existing root (populated only when + the root is absent from the cache). A repository whose index grows or shrinks across a + tier boundary mid-run keeps its stale tier until the root set changes or the app + relaunches. Likely acceptable (tiers span 4x ranges) but diverges from the plan's + stated refresh intent. diff --git a/docs-ai/037-line-diff-tracking/002-adaptive-debounce-and-untracked-lines.md b/docs-ai/037-line-diff-tracking/002-adaptive-debounce-and-untracked-lines.md new file mode 100644 index 00000000..9e1e68d3 --- /dev/null +++ b/docs-ai/037-line-diff-tracking/002-adaptive-debounce-and-untracked-lines.md @@ -0,0 +1,58 @@ +# 037 — Amendment: Adaptive Debounce & Untracked Lines in Badge + +## Context + +The #365 debounce constants (5 s HEAD / 30 s FSEvents) were chosen for worst-case large +repositories. Fork issue #488 reported the consequence for normal repos: edit a file and +the badge takes 30+ seconds to update — and keeps resetting if you keep editing. +Benchmarks (2026-06-22 plan doc, absorbed into this entry) showed the cost of +`git diff HEAD --shortstat` scales with dirty-file count, not repo size alone: ~14 ms on +a clean 5 000-file repo but ~2.0 s with 20 000 dirty files. A single global debounce +cannot serve both audiences. + +A second inconsistency: the badge ran only `git diff HEAD --shortstat`, which reports +tracked changes, while the diff window (⌘⇧Y) includes untracked files via +`git ls-files --others --exclude-standard`. Creating a new file showed `+0` on the badge +but a listed file in the viewer ([003-diff-window](../003-diff-window/000-plan.md)). + +## Change + +PR #491 (2026-06-22, closes #488), implementing the plan doc: + +- **Adaptive tiers**: read the repository's tracked-file count from the git index binary + header (entry count is a big-endian `UInt32` at byte offset 8 — a 12-byte read, no + subprocess), cache it per repository root, and map to a timing tier: + + | Tier | Tracked files | HEAD debounce | FSEvents debounce | + |---|---|---|---| + | Small | < 5 000 | 1 s | 2 s | + | Medium | 5 000 – 20 000 | 2 s | 5 s | + | Large | > 20 000 | 5 s | 15 s | + + The 300 s safety refresh stays uniform. The large tier keeps (slightly tightens) the + #365 conservative behavior; `observeLineDiffsAutomatically = false` (#377) remains the + hard opt-out for repos where even 15 s is too aggressive. +- **Untracked lines**: `GitClient.lineChanges(at:)` runs `git ls-files --others + --exclude-standard` concurrently with the shortstat diff (`async let`) and adds the + line counts of untracked files to `added` — same `(added:removed:)` return type, no + badge layout change. Files with a NUL byte in the first 8 KB are treated as binary and + skipped (matching git's heuristic); counting is pure in-process I/O. + +Deliberately unchanged (plan doc non-goals): the `isLineChangesActive` gating, PR +polling ([028-pr-status-tracking](../028-pr-status-tracking/000-plan.md)), user-visible +interval settings, and the `--shortstat` command itself. + +## Refs + +- PR #491 — "Adaptive line-diff debounce and untracked lines in badge" +- Fork issue #488; design: `doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md` + (absorbed here; original removed in the docs-ai migration) + +## Current state + +`LineChangesTiming` and `repositoryLineChangesTimings` in +`supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift`; +`indexEntryCount(at:)`, `countLinesInFiles(_:relativeTo:)` and the concurrent +`lineChanges(at:)` in `supacode/Clients/Git/GitClient.swift`. Tests in +`supacodeTests/WorktreeInfoWatcherManagerTests.swift` and +`supacodeTests/GitClientLineChangesTests.swift`. diff --git a/docs-ai/037-line-diff-tracking/003-deferred-refresh-after-commit.md b/docs-ai/037-line-diff-tracking/003-deferred-refresh-after-commit.md new file mode 100644 index 00000000..ca83c793 --- /dev/null +++ b/docs-ai/037-line-diff-tracking/003-deferred-refresh-after-commit.md @@ -0,0 +1,42 @@ +# 037 — Amendment: Deferred Refresh After Commit + +## Context + +After committing, the sidebar badge kept showing stale `+N/-M` for up to 5 minutes even +though `git diff HEAD` was already 0/0 — only the 300 s safety refresh eventually +corrected it. + +Root cause: an interaction between two #365-era mechanisms. Worktrees added after the +initial load go into `deferredLineChangeIDs`, and the central `emit(_:)` function drops +`.filesChanged` events for deferred worktrees. The HEAD watcher path +(`scheduleFilesChanged`, fired on commit/branch switch) emitted `.filesChanged` +*directly* after its debounce — so for a deferred worktree the event was silently +swallowed, and nothing ever cleared the deferred flag. + +## Change + +- PR #508 (2026-06-25): route `scheduleFilesChanged` through + `scheduleLineChangesRefresh`, whose timer fires `emitLineChangesChanged` — which + removes the worktree from `deferredLineChangeIDs` before emitting, so the event can no + longer be dropped. The faster HEAD-path timing (1/2/5 s per tier, vs the 2/5/15 s + FSEvents debounce) is preserved by passing `filesChangedDebounce` as the delay. +- PR #511 (2026-06-25, merged together with #508's fix): made the regression testable + without real file-system event delivery by extracting the HEAD watcher + `DispatchSource` behind a `WorktreeHeadEventMonitoring` protocol + (`DispatchSourceWorktreeHeadEventMonitor` in production), and added + `headWatcherEventNotBlockedByDeferredLineChanges()` — load one worktree, add a second + (which becomes deferred), emit a HEAD event for it, assert `.filesChanged` fires after + the debounce. + +## Refs + +- PR #508 — "fix: sidebar diff badge not refreshing after commit (deferredLineChangeIDs gate)" +- PR #511 — "test: cover deferred HEAD watcher refresh" + +## Current state + +`scheduleFilesChanged` in +`supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift` carries +an inline comment explaining the routing; `WorktreeHeadEventMonitoring` lives in +`supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift`; the +regression test is in `supacodeTests/WorktreeInfoWatcherManagerTests.swift`. diff --git a/docs-ai/038-docs-agent-manual/000-plan.md b/docs-ai/038-docs-agent-manual/000-plan.md new file mode 100644 index 00000000..9f6949f3 --- /dev/null +++ b/docs-ai/038-docs-agent-manual/000-plan.md @@ -0,0 +1,101 @@ +# 038 — docs/ Agent-Facing Manual: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-07 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #408, #409, #410, #411, #412 (manual itself created in direct commit `49235800`) | +| **Sources** | PR descriptions #408–#412, commit `49235800`, `docs/README.md` | +| **Related** | [013-prowl-cli](../013-prowl-cli/000-plan.md) (`docs/components/cli.md`), [001-fork-bootstrap-and-release-pipeline](../001-fork-bootstrap-and-release-pipeline/000-plan.md) (release flow the sync hook lives in), `docs-ai/README.md` + `.claude/skills/write-ai-doc/SKILL.md` (the follow-on history-record system, see Amendments) | + +## Background + +By June 2026 Prowl had accumulated a large user-facing surface — Canvas, Shelf, +Active Agents, the `prowl` CLI, keybindings, per-repo settings — with no manual. +The design insight behind the fix: **the primary reader of Prowl docs is an AI +agent, not a human**. Users point their coding agent at the docs and ask +questions ("how do I broadcast a command to every agent?"); the agent reads the +relevant file and answers. That framing shaped everything: plain Markdown, no +images required, descriptive filenames, keyword-searchable, quotable. + +A manual alone is not enough — three follow-on problems were anticipated: + +1. **Rot**: docs drift from the implementation unless something forces re-sync. +2. **Release freshness**: a release tag should always contain docs that match it. +3. **Distribution**: end users (and their agents) must be able to find the docs + without cloning the repo. + +## Goals + +- Ship a complete agent-readable manual under `docs/`: an index (`README.md`), + a pitch (`overview.md`), a mental model (`concepts.md`), one file per feature + under `components/`, and exact-lookup tables under `reference/` that name + their source-of-truth Swift files. +- Keep it accurate with two complementary mechanisms: a one-line change-time + rule in `AGENTS.md`, plus a periodic diff-driven audit (the `sync-docs` + skill) that is deliberately conservative. +- Hook the audit into the release flow so docs ship correct inside every tag. +- Make the docs reachable by end users: bundle them into the app, add an + "Ask Agent About Prowl" help action, and put a copyable agent prompt in the + repository `README.md`. + +### Non-goals + +- A human-oriented docs website (deferred; the baseline file was deliberately + named `.sync-meta.json` as a dotfile so a future site won't render it). +- Rewriting or restyling docs during sync — sync only restores factual accuracy. + +## Design / Approach + +- **Manual structure** (`49235800`, 20 files): `docs/README.md` is the map; + `components/*.md` are self-contained per-feature manuals; `reference/` + (`keyboard-shortcuts.md`, `settings-fields.md`) holds exhaustive tables. +- **Maintenance layer 1 — change-time rule** (#408): a single line in + `AGENTS.md`'s `## Rules`: when you change user-facing behavior, update the + matching `docs/` file in the same change. One line only, to avoid bloating + every session's context. +- **Maintenance layer 2 — `sync-docs` skill** (#408): diff `HEAD` against a + **committed** commit baseline, scoped to `supacode/` / `ProwlCLI/` etc.; map + changed source → affected docs; verify claims against source-of-truth files + (`AppShortcuts.swift`, settings types, `ProwlCLI/`); apply minimal edits only + where behavior actually changed; bump the baseline; flag large/ambiguous + changes for a human instead of applying silently. +- **Baseline storage** (#410): moved from `.claude/skills/sync-docs/baseline.md` + to `docs/.sync-meta.json` — mutable state shouldn't churn the skill folder, + and it's conceptually docs metadata. JSON (`last_synced_commit`, + `last_synced_date`, `note`) so release tooling can read it with `jq`. +- **Release hook** (#411): a sync-docs step in the `release` skill after the + clean-tree check and **before** version bump + tag ("never tag first"), so + the docs commit is an ancestor of the release tag and ships inside it. +- **Distribution** (#412): an `embed-docs` Makefile target rsyncs `docs/` into + `Contents/Resources/docs` (excluding `.sync-meta.json`; output gitignored), + wired into `build-app`/`archive`/`test`; `SupacodePaths.bundledDocs*` resolve + the runtime path; an "Ask Agent About Prowl" sheet (sidebar Help menu + + macOS Help menu) hands the user a ready-to-paste, localized prompt pointing + their agent at the bundled docs; the repo `README.md` gets an equivalent + English prompt pointing at the raw GitHub `docs/README.md`. + +## Alternatives & decisions + +- **Baseline value at release sync** (#411): set `last_synced_commit` to the + HEAD at sync time (the commit being released), not to the subsequent doc + commit — a commit can't contain its own hash, so the alternative needs a + second commit and is circular. No diff difference either way, since the + doc/bump/CHANGELOG commits touch no files inside the sync scope. +- **Conservative-by-default sync** (#408): docs change only when a documented + fact is wrong or a real feature was added/removed; internal refactors and + wording drift are ignored. Chosen explicitly to keep churn low. +- **Help action without TCA** (#412): a lightweight shared `@Observable` + presenter + a sheet on the main window, avoiding reducer changes for a + purely presentational feature. +- **Localized prompts hardcoded per language** (#412): English, Simplified and + Traditional Chinese, Japanese, with English fallback — resolved from the + *system* preferred language (the app itself ships English-only), and every + variant asks the agent to reply in the user's preferred language. + +## Amendments + +- Updated 2026-07-12: `docs-ai/` history-record system + `write-ai-doc` skill + added as the history-side complement to `docs/` — see + [002-docs-ai-follow-on.md](002-docs-ai-follow-on.md) diff --git a/docs-ai/038-docs-agent-manual/001-action.md b/docs-ai/038-docs-agent-manual/001-action.md new file mode 100644 index 00000000..2078d42b --- /dev/null +++ b/docs-ai/038-docs-agent-manual/001-action.md @@ -0,0 +1,57 @@ +# 038 — docs/ Agent-Facing Manual: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-07 | Initial agent-facing manual under `docs/` (20 files, ~1900 lines: index, overview, concepts, `components/`, `reference/`) | commit `49235800` | +| 2026-06-07 | `AGENTS.md` change-time rule + `sync-docs` audit skill with committed baseline (initially `.claude/skills/sync-docs/baseline.md`, starting at `49235800`) | PR #408 | +| 2026-06-07 | Word-by-word accuracy audit of the manual against `supacode`/`ProwlCLI` sources; 13 files corrected (wrong behavior claims, config values like `dockBounceMode = continuous`, `⌥⌃` glyph order), 7 audited clean | PR #409 | +| 2026-06-07 | Baseline relocated to `docs/.sync-meta.json` (dotfile JSON: `last_synced_commit` / `last_synced_date` / `note`) | PR #410 | +| 2026-06-07 | `sync-docs` hooked into the release skill as its own step: clean-tree check → docs sync + commit → version bump → tag | PR #411 | +| 2026-06-07 | Docs bundled into the app (`embed-docs` Makefile target → `Contents/Resources/docs`), "Ask Agent About Prowl" help sheet (localized en/zh-Hans/zh-Hant/ja), copyable agent prompt in repo `README.md` | PR #412 | + +## Outcome & current state (as of 2026-07-12) + +- **Manual**: `docs/README.md` (index, states the agent-first framing), + `docs/overview.md`, `docs/concepts.md`, 16 component manuals under + `docs/components/` and `docs/reference/keyboard-shortcuts.md` + + `docs/reference/settings-fields.md`. The set has grown per the change-time + rule since creation (e.g. `docs/components/workspaces.md` added 2026-06-09). +- **Sync machinery**: `.claude/skills/sync-docs/SKILL.md` (diff-driven, + conservative rules intact); baseline lives in `docs/.sync-meta.json` — + currently `last_synced_commit: 168d8e9c…`, `last_synced_date: 2026-07-10` + (release prep), showing the loop is actually exercised. +- **Rules**: the one-line docs rule is present in both `AGENTS.md` and + `CLAUDE.md` (`## Rules`: update the matching `docs/` file when changing + user-facing behavior; run `sync-docs` for a full audit). +- **Release hook**: `.claude/skills/release/SKILL.md` runs `sync-docs` against + `docs/.sync-meta.json` before bump + tag, as designed. The release process + itself is documented in + `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`. +- **Bundling**: `Makefile` `embed-docs` target, wired into + `build-app`/`archive`/`test`; runtime resolution in + `supacode/Support/SupacodePaths.swift` (`bundledDocsURL`, + `bundledDocsReadmePath`, `bundledDocsDirectoryPath`). +- **Help action**: `supacode/Features/Help/AskAgentHelpPresenter.swift`, + `AskAgentHelpPrompt.swift` (system-locale → language key with + Hans/Hant disambiguation), `AskAgentHelpView.swift`; wired in + `supacode/App/supacodeApp.swift` (sheet + macOS Help menu item) and + `supacode/Features/Repositories/Views/SidebarFooterView.swift` (sidebar Help + menu). Tests: `supacodeTests/AskAgentHelpPromptTests.swift`. +- **README prompt**: repo `README.md` "Meet Prowl through your agent" section + with a copyable prompt pointing at the raw GitHub `docs/README.md`, plus a + pointer to Help → Ask Agent About Prowl for the bundled, localized variant. +- **Division of labor with this doc set**: `docs/` documents current behavior + for users and their agents; `docs-ai/` (this system) records history and + decisions — see [002-docs-ai-follow-on.md](002-docs-ai-follow-on.md). + +## Deviations from plan + +None known. The manual itself landed as a direct commit on `main` +(`49235800`) rather than via a PR; the five PRs the same day built the +maintenance, release, and distribution machinery around it. + +## Open questions + +None. diff --git a/docs-ai/038-docs-agent-manual/002-docs-ai-follow-on.md b/docs-ai/038-docs-agent-manual/002-docs-ai-follow-on.md new file mode 100644 index 00000000..0ade6f83 --- /dev/null +++ b/docs-ai/038-docs-agent-manual/002-docs-ai-follow-on.md @@ -0,0 +1,36 @@ +# 038 — docs/ Agent-Facing Manual: Amendment — docs-ai History Record System + +## Context + +`docs/` deliberately documents only *current* behavior: the `sync-docs` skill +keeps it minimal and present-tense, so design rationale and the evolution of +features had no durable home — they were scattered across PR bodies and a +fork-private notes directory that is being dissolved into `docs-ai/`. + +## Change + +On 2026-07-12 a second documentation set was introduced as the history-side +complement to `docs/`: + +- `docs-ai/` — numbered, spec-driven work records: `000-plan.md` written before + implementation, `001-action.md` after, `002+` amendments for later waves; + indexed by `docs-ai/README.md`. Non-numbered files inside an entry folder are + living documents (runbooks, contracts) that keep being updated. +- The `write-ai-doc` skill (`.claude/skills/write-ai-doc/SKILL.md`) creates and + amends entries. +- `AGENTS.md` / `CLAUDE.md` gained a rule to write the plan entry before + starting medium/large features, decision-shaping fixes, or non-trivial + investigations, and a header pointer to `docs-ai/README.md`. +- Prior fork history (including this entry) was backfilled retrospectively. + +Division of labor going forward: `docs/` is the user-facing behavior manual +(what Prowl does today, kept accurate by `sync-docs`); `docs-ai/` is the +engineering history (how and why it got that way). `sync-docs` governs only +`docs/`. + +## Refs + +- `docs-ai/README.md` +- `.claude/skills/write-ai-doc/SKILL.md` +- `AGENTS.md` / `CLAUDE.md` (write-ai-doc rule in `## Rules`; `docs-ai/` + pointer in the header line) diff --git a/docs-ai/039-gh-cli-hardening/000-plan.md b/docs-ai/039-gh-cli-hardening/000-plan.md new file mode 100644 index 00000000..b6422d8f --- /dev/null +++ b/docs-ai/039-gh-cli-hardening/000-plan.md @@ -0,0 +1,96 @@ +# 039 — gh CLI Hardening: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-08 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #418, #437 (later waves: #493, #541 — see Amendments) | +| **Sources** | PR descriptions #418/#437/#493/#541, upstream review ledger entries 2026-06-09 and 2026-07-09 ([upstream-ledger.md](../017-upstream-sync-process/upstream-ledger.md)) | +| **Related** | [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md), [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), `docs/components/github-pull-requests.md`, `docs/reference/settings-fields.md` | + +## Background + +Prowl's GitHub integration (PR chips, merge/close actions, workflow-run logs) shells out +to the `gh` CLI, and git discovery shells out to `git`/`wt`. To pick up the user's real +PATH and version managers, one-shot commands run through a **login shell** wrapper in +`supacode/Clients/Shell/ShellClient.swift`. That wrapper is the root of a whole fragility +class: + +- A login shell sources `.zprofile` / `.zlogin` / rc files before the command runs, so a + banner or version-manager line (nvm, mise, …) can prepend to captured stdout and corrupt + the JSON `gh` prints. `JSONDecoder` then fails with an opaque *"The data couldn't be + read because it isn't in the correct format"* in the GitHub settings pane — even though + `gh` works fine in the user's terminal. onevcat runs zsh startup scripts, so this was a + high-probability hit (upstream hit it too: upstream #378). +- Separately, `gh` supports multiple authenticated hosts and accounts, but the client + flattened auth status to a single account picked from an **unordered dictionary** — + non-deterministic with more than one active host — and there was no way to pin a + repository to a specific GitHub identity. Fork/multi-account workflows (e.g. a work + account and a personal account on github.com) could hit the wrong account. + +## Goals + +- Tolerate arbitrary shell-startup noise around `gh` JSON output instead of failing with + an opaque decode error; when decoding still fails, produce an actionable message. +- Make multi-account `gh` setups first-class: show every authenticated host/account, and + let a repository pin the identity used for its GitHub operations. +- Keep background PR refresh batches from mixing accounts. + +**Non-goals** + +- Replacing `gh` with direct API calls or a GitHub SDK — the CLI remains the integration + surface. +- Changing how the interactive terminal spawns the user's shell (only one-shot command + wrapping is in scope). + +## Design / Approach + +Two strands, landed a few days apart: + +**1. Login-shell noise tolerance (#418, port of upstream #378).** +`GithubCLIOutput` (in `supacode/Clients/Github/GithubCLIClient.swift`) scans captured +stdout for every *balanced* top-level JSON value (`{...}` / `[...]`), skipping stray +unbalanced openers so leading noise cannot swallow a real payload, and decodes the +**last** decodable span (gh prints its JSON after any leading noise). All six gh JSON +decode sites route through it; `latestRun` and `resolveRemoteInfo` use `decodeIfPresent` +so a genuinely absent payload stays `nil`. Failure modes get distinct, readable errors: +no payload → shell-pollution message; payload present but undecodable → gh-version +message. The port was adapted to the fork's client, which keeps a fork-specific +`CrossRepoPullRequestResponse` decode path. As a drive-by, `GithubAuthStatusParsing.activeAccount` +got a deterministic host order (prefer `github.com`, then sorted), fixing the latent +multi-host nondeterminism. + +**2. Per-repo GitHub CLI identities (#437, fork feature).** +- `GithubAccountOverride` (`supacode/Clients/Github/GithubCLIModels.swift`): a + host+login pair, persisted per repository as `githubAccountOverride` in + `supacode/Features/Settings/Models/RepositorySettings.swift`. +- Every GithubCLIClient operation takes an optional override; `withExpectedGithubAccount` + runs a scoped `gh auth switch` to the override's account, executes, then switches the + host back to the previously active account. A per-host `GithubAccountSwitchLock` actor + serializes switches so concurrent operations cannot interleave identities. +- Settings → GitHub (`supacode/Features/Settings/Views/GithubSettingsView.swift`) lists + all authenticated hosts/accounts instead of flattening to one; repo settings gain an + identity picker (`RepositoryGithubIdentityViewModel` in + `supacode/Features/Settings/Views/RepositorySettingsView.swift`). +- `PullRequestRefreshCoordinator` batches background PR refreshes by + `BatchKey(host, accountOverride)` so a batch never mixes accounts. + +## Alternatives & decisions + +- **Decode-last-span over decode-first / regex stripping** (#418, inherited from upstream + #378): scanning balanced spans and preferring the last one is robust against both + leading banners and stray braces inside noise; stripping heuristics are not. +- **Scoped `gh auth switch` over per-command credential injection** (#437): `gh` has no + per-invocation account flag, so switch-execute-restore under a per-host lock is the + workable primitive; the cost is serialization of same-host operations. +- Later waves made two more decisions of note — rejecting an error-string allowlist in + favor of inverting the login-shell fallback (see 002), and keeping upstream's + `__supacode_login_argv` variable name to minimize sync friction (see 003). + +## Amendments + +- Updated 2026-06-22: invert login-shell fallback for git detection (#493, replaces + allowlist approach #487) — see [002-login-shell-fallback-inversion.md](002-login-shell-fallback-inversion.md) +- Updated 2026-07-08: port of four upstream gh-detection/login-shell fixes (#541) — see + [003-upstream-hardening-batch.md](003-upstream-hardening-batch.md) diff --git a/docs-ai/039-gh-cli-hardening/001-action.md b/docs-ai/039-gh-cli-hardening/001-action.md new file mode 100644 index 00000000..8f15ee50 --- /dev/null +++ b/docs-ai/039-gh-cli-hardening/001-action.md @@ -0,0 +1,54 @@ +# 039 — gh CLI Hardening: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-08 | Tolerate login-shell noise in gh JSON output: `GithubCLIOutput` balanced-span scanner, all six decode sites routed through it, distinct no-payload vs undecodable errors, deterministic `activeAccount` host order. Port of upstream #378. | PR #418 | +| 2026-06-13 | Per-repo GitHub CLI identities: `GithubAccountOverride` in repo settings, scoped `gh auth switch` with per-host lock, multi-account GitHub settings pane, PR refresh batches keyed by host+identity. Docs updated. | PR #437 | +| 2026-06-22 | Invert login-shell fallback for git detection: retry under login shell for **all** shell errors except a confirmed "not a git repository". Fixes repos shown as plain folders when Xcode CLI tools are unusable (fork issue #486). | PR #493 | +| 2026-07-08 | Port four upstream gh-detection/login-shell fixes: non-POSIX shell fallback to `/bin/zsh`, argv capture before rc sourcing, positional clearing before rc sourcing, fixed-path gh fallback. Ports of upstream #410/#460/#482/#535. | PR #541 | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Clients/Github/GithubCLIClient.swift` — `GithubCLIOutput` (balanced-span + scanning, decode-last, `noPayloadMessage` / `undecodableMessage`), + `GithubAuthStatusParsing.activeAccount` (github.com-first deterministic order), + `GithubAccountSwitchLock` actor, and `withExpectedGithubAccount` wrapping every + account-sensitive operation (batch PR queries, merge/close/ready, run logs, reruns). +- `supacode/Clients/Github/GithubCLIModels.swift` — `GithubAccountOverride` (normalized + host+login), `GithubAuthStatusResponse` / `GithubAuthAccountStatus` for the + multi-account settings pane. +- `supacode/Features/Settings/Models/RepositorySettings.swift` — persisted + `githubAccountOverride`, normalized on decode. +- `supacode/Features/Settings/Views/GithubSettingsView.swift` and + `RepositorySettingsView.swift` (`RepositoryGithubIdentityViewModel`) — the settings UI. +- `supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift` — + batches keyed by `BatchKey(host, accountOverride)`; `cancelHost` clears per-host state. +- `supacode/Clients/Git/GitClientShellHelpers.swift` — inverted + `shouldFallbackToLoginShell` (fallback unless output contains "not a git repository"); + call site in `supacode/Clients/Git/GitClient.swift`. +- `supacode/Clients/Shell/ShellClient.swift` — login-shell wrapper drives only + zsh/bash/fish; anything else falls back to `/bin/zsh`. The POSIX one-shot command + captures `$@` into `__supacode_login_argv`, clears positionals with `set --`, sources + the rc file, then `exec`s the captured argv. +- `supacode/Clients/Github/GithubCLIExecutableResolver.swift` — when shell PATH probes + miss `gh`, falls back to `/opt/homebrew/bin/gh`, `/usr/local/bin/gh`, + `~/.local/bin/gh`, with an info log for traceability. +- Behavior docs: `docs/components/github-pull-requests.md` (identity override + switch + semantics), `docs/reference/settings-fields.md` (`githubAccountOverride`). +- Tests: `supacodeTests/GithubCLIOutputTests.swift`, `GithubCLIClientTests.swift`, + `GitClientShellFallbackTests.swift`, `ShellClientLoginShellTests.swift`, + `PullRequestRefreshCoordinatorTests.swift`. + +## Deviations from plan + +None known — the two later waves extended the same theme and are recorded as amendments +rather than deviations. + +## Open questions + +- `withExpectedGithubAccount` mutates global `gh` state (auth switch + restore) under a + per-host in-process lock; an external `gh auth switch` run by the user (or a crash + between switch and restore) during the window would leave the host on the override + account. Accepted trade-off of the CLI-based approach, but undocumented. diff --git a/docs-ai/039-gh-cli-hardening/002-login-shell-fallback-inversion.md b/docs-ai/039-gh-cli-hardening/002-login-shell-fallback-inversion.md new file mode 100644 index 00000000..ec672df1 --- /dev/null +++ b/docs-ai/039-gh-cli-hardening/002-login-shell-fallback-inversion.md @@ -0,0 +1,33 @@ +# 039 — Amendment: Invert Login-Shell Fallback for Git Detection + +## Context + +Git commands run directly first and retry under a login shell only when the direct run +fails in a way a login shell could fix. The original logic kept an **allowlist** of +retryable errors, so failure modes outside the list — notably Xcode CLI-tool shim +failures (unaccepted license, broken active developer path) — never triggered the retry. +Result: real git repositories were shown as plain folders (fork issue #486). + +## Change + +PR #493 (merged 2026-06-22) inverts `shouldFallbackToLoginShell` +(`supacode/Clients/Git/GitClientShellHelpers.swift`): retry under a login shell for +**every** shell error except the one case where retrying is provably pointless — git ran +and confirmed "not a git repository". The worst case of an unnecessary fallback is one +extra failing shell invocation. + +Decision: the competing PR #487 pattern-matched specific Xcode error strings +(`"xcode license"`, `"invalid active developer path"`, …) and was closed unmerged — +Apple can reword, add, or localize those messages, while the inverted rule covers unknown +future failure modes by default. + +## Refs + +- PR #493 (closes fork issue #486; supersedes #487) +- Tests: `supacodeTests/GitClientShellFallbackTests.swift` (fallback on exit 127 / + license errors / unknown errors; no fallback on genuine non-repo or non-shell errors) + +## Current state + +Logic unchanged since merge; the fallback call site lives in +`supacode/Clients/Git/GitClient.swift`. diff --git a/docs-ai/039-gh-cli-hardening/003-upstream-hardening-batch.md b/docs-ai/039-gh-cli-hardening/003-upstream-hardening-batch.md new file mode 100644 index 00000000..bd80e59b --- /dev/null +++ b/docs-ai/039-gh-cli-hardening/003-upstream-hardening-batch.md @@ -0,0 +1,39 @@ +# 039 — Amendment: Upstream gh-Detection / Login-Shell Hardening Batch + +## Context + +The 2026-07-09 upstream review batch (see +[upstream-ledger.md](../017-upstream-sync-process/upstream-ledger.md) and +[017-upstream-sync-process](../017-upstream-sync-process/000-plan.md)) found four +upstream fixes to the same gh/login-shell subsystem that #418 had partially ported; the +fork still carried the pre-fix form of all four defects. + +## Change + +PR #541 (merged 2026-07-08) ports all four into `supacode/Clients/Shell/ShellClient.swift` +and `supacode/Clients/Github/GithubCLIExecutableResolver.swift`: + +| Upstream | Fix | +| --- | --- | +| upstream #410 | Non-POSIX login shells (nushell, pwsh, csh — and sh/dash/ksh, which cannot parse the zsh rc snippet) fall back to `/bin/zsh` for one-shot commands instead of failing every git/wt/gh invocation as a bogus "not a git repository". The interactive terminal still uses the user's real shell. | +| upstream #460 | Capture `$@` into `__supacode_login_argv` before sourcing the rc file — an rc running `set --` could wipe the command before `exec`, making gh undetectable (upstream issue #441). | +| upstream #482 | Clear live positionals (`set --`) before sourcing — a dual-mode rc script dispatching on `$1` (e.g. `fzf-git.sh`) could see the probe's arguments, hit its own `exit`, and kill the probe shell. | +| upstream #535 | When both `which gh` probes fail (broken PATH/rc), fall back to `/opt/homebrew/bin/gh`, `/usr/local/bin/gh`, `~/.local/bin/gh`, with a breadcrumb log. | + +Structural adaptation: upstream's ShellClient lives in `SupacodeSettingsShared`; the +fork's is `supacode/Clients/Shell/ShellClient.swift`, and the fork had already extracted +`GithubCLIExecutableResolver` into its own file. Decision: keep upstream's +`__supacode_login_argv` variable name to minimize future sync friction. + +## Refs + +- PR #541 (ports upstream #410 / #460 / #482 / #535) +- Tests: `supacodeTests/ShellClientLoginShellTests.swift` (drivable-shell selection, fish + snippet isolation, capture → `set --` → source ordering), + `supacodeTests/GithubCLIClientTests.swift` (fallback path resolution and ordering) + +## Current state + +All four fixes present as described; the login-shell wrapper drives only zsh/bash/fish +(`drivable` set in `ShellClient.swift`) and logs `Using fallback: /bin/zsh` when a +non-drivable shell is replaced. diff --git a/docs-ai/040-automatic-open-in/000-plan.md b/docs-ai/040-automatic-open-in/000-plan.md new file mode 100644 index 00000000..9e5d1cc5 --- /dev/null +++ b/docs-ai/040-automatic-open-in/000-plan.md @@ -0,0 +1,112 @@ +# 040 — Automatic Open In: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-13 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #439 (anchor), precursors #217, #264; later port #542 | +| **Sources** | PR #217/#264/#439/#542 descriptions, change-list 2026-05-08 and 2026-07-09 review batches (now `../017-upstream-sync-process/upstream-ledger.md`) | +| **Related** | [017-upstream-sync-process](../017-upstream-sync-process/000-plan.md), [028-pr-status-tracking](../028-pr-status-tracking/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md) (#509 canvas toolbar code-host actions), `docs/components/repositories-and-worktrees.md`, `docs/reference/settings-fields.md` | + +## Background + +This entry covers how a worktree is opened *outside* Prowl — in an editor, terminal, +git client, or the repository's code host in the browser. + +Two precursor gaps were fixed before the anchor work: + +- **Code host was PR-gated and GitHub-only** (#217, 2026-04-19). The "open on GitHub" + shortcut assumed a pull request existed and a GitHub-shaped remote; with no PR the + action silently did nothing, and GitLab-style remotes could not open anything. +- **Android Studio was missing** from the open-app list (#264, 2026-05-08, port of + upstream #262 `6fff0218` from the 2026-05-08 review batch). + +The anchor problem (#439): the toolbar **Open In** control's *Automatic* mode simply +picked the first installed app from a fixed priority list (Cursor → Zed → VS Code → …) +regardless of what the repository contained — a Swift package opened in Cursor even when +Xcode was the obvious choice. Several mainstream apps (iTerm2, Sublime Text, Tower, most +of the JetBrains family) were also unsupported, and toolbar redraws paid a repeated +app-icon rasterization cost. + +## Goals + +- Make the `auto` open action project-aware: detect the project ecosystem from the + worktree's contents and prefer a specialist app, falling back to the generic priority. +- Keep explicit selections untouched: a per-repo `openActionID` or global + `defaultEditorID` must be respected exactly as before; project awareness applies only + to the `auto` path. +- Expand the supported app set (JetBrains family, iTerm2, Sublime Text, Tower). +- Fix the measured per-render icon cost in the toolbar without adding caches that hide + newly installed apps. +- (Precursor #217) make the code-host action host-generic and PR-optional: open the PR + when one exists, otherwise the repository homepage. + +### Non-goals + +- PR management actions stay GitHub-only (#217 broadened only the *open in browser* + action to generic hosts). +- No persisted detection cache; project detection is recomputed from a single shallow + directory listing at resolution time. + +## Design / Approach + +Reconstructed from the PR #439 description. + +**Project detection.** New `WorktreeProjectKind` (`supacode/Domain/WorktreeProjectKind.swift`) +detects the project type from one shallow listing of the worktree's top-level entries, +ordered from most to least specific marker: `.xcodeproj`/`.xcworkspace`/`Package.swift`/ +`Project.swift` → **apple**, Gradle files → **android**, `.sln`/`.csproj` → **dotnet**, +`pom.xml` → **java**, `go.mod` → **golang**, `Cargo.toml` → **rust**, `CMakeLists.txt` → +**cpp**, `composer.json` → **php**, `Gemfile` → **ruby**, Python manifests → **python**, +and `package.json` deliberately last → **web** (almost any repo carries one for tooling). + +**Specialist-first resolution.** Each kind maps to `preferredActions` tried before the +generic `OpenWorktreeAction.defaultPriority` (apple → Xcode; android → Android Studio, +then IntelliJ; dotnet → Rider; golang → GoLand; rust → RustRover; web → WebStorm; …), +falling back seamlessly when the specialist is not installed. The worktree's +`workingDirectory` is threaded through all three reducer resolution sites +(`worktreeSettingsLoaded`, Canvas focus, `settingsChanged`) into +`OpenWorktreeAction.fromSettingsID(_:defaultEditorID:workingDirectory:)`. Picking an app +from the dropdown still pins it for the repo; an **Automatic** menu entry (added inside +the PR, commit `e8d8ff48`) clears the pin back to project-aware selection. + +**New apps.** iTerm2, Sublime Text, Tower, plus the remaining JetBrains IDEs — Rider, +GoLand, CLion, PhpStorm, RubyMine — opened via the existing JetBrains CLI-arguments path +(`NSWorkspace.OpenConfiguration.arguments`) rather than Apple Events. + +**Icon performance (measure first).** Benchmarks showed +`urlForApplication(withBundleIdentifier:)` at ~3.5µs warm — not a bottleneck, left +uncached so newly installed apps appear immediately. The real waste was +`icon(forFile:)` (~1.1ms cold) plus a `lockFocus` rasterizing resize on every toolbar +redraw. Menu icons are now pre-resized once and cached (cache stores hits only), and the +per-render resize was removed from `OpenWorktreeActionMenuLabelView`. + +**Code-host fallback (#217).** Generic remote parsing +(`GitClient.parseRepositoryWebInfo` → `GitRemoteWebInfo` with host, repository path, and +optional port) replaces GitHub-specific parsing; the action opens the PR when one exists +and otherwise the repository homepage. A dedicated `supportsCodeHost` capability was +split from `supportsPullRequests` so non-GitHub remotes surface the browser action +without implying PR support. + +## Alternatives & decisions + +- **Heuristic detection over configuration.** Project kind is inferred from marker + files rather than a new setting; explicit per-repo/global selections remain the + configuration surface and always win over the heuristic. +- **No LaunchServices caching.** Installed-app lookups were measured cheap and left + uncached so a newly installed editor shows up without invalidation; only resolved + icons are cached. +- **Upstream `c38c325d` #423 OpenTarget/OpenBehavior refactor rejected** (2026-07-09 + review batch): new editors are added as cases in the fork's existing enum shape + instead of adopting upstream's restructure — see [002-upstream-editor-ports.md](002-upstream-editor-ports.md). +- **`settingsID` raw values follow upstream literals** (`zed-preview`, `intellijEAP`, + `nova`) so persisted selections stay portable across syncs; `intellijEAP` knowingly + breaks the fork's kebab-case convention. +- **Host-generic open, GitHub-only PR management** (#217): broadening stopped at the + browser action; PR state tracking stayed GitHub-scoped (see 028). + +## Amendments + +- Updated 2026-07-08: Zed Preview / IntelliJ IDEA EAP / Nova ported from upstream, with + fork-specific project-kind integration — see [002-upstream-editor-ports.md](002-upstream-editor-ports.md) diff --git a/docs-ai/040-automatic-open-in/001-action.md b/docs-ai/040-automatic-open-in/001-action.md new file mode 100644 index 00000000..7b92d1e9 --- /dev/null +++ b/docs-ai/040-automatic-open-in/001-action.md @@ -0,0 +1,72 @@ +# 040 — Automatic Open In: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-04-19 | Code-host opening fallback: generic `GitRemoteWebInfo` parsing, open PR when present else repository homepage, `supportsCodeHost` capability split from PR support (commits `d62827e6`, `9822ae62`, `ed1d2f69`) | PR #217 | +| 2026-04-20 | Code-host actions labeled with the detected host's display name ("Open on GitLab", falling back to "Open on Code Host") | commit `afdda3e0` (direct to main) | +| 2026-05-08 | Android Studio open action via the JetBrains CLI-arguments path (`com.google.android.studio`); port of upstream #262 from the 2026-05-08 review batch | PR #264 | +| 2026-06-13 | Project-aware Automatic Open In: `WorktreeProjectKind` detection, specialist-first resolution, new apps (iTerm2, Sublime Text, Tower, Rider, GoLand, CLion, PhpStorm, RubyMine), menu-icon caching, "Automatic" menu entry to clear a pinned app (commits `e423d2d8`, `2940a94e`, `e8d8ff48`, `3a24efce`) | PR #439 | +| 2026-07-08 | Zed Preview, IntelliJ IDEA EAP, Nova ported from upstream; IDEA EAP joins android/java project-kind fallbacks | PR #542 — see [002-upstream-editor-ports.md](002-upstream-editor-ports.md) | + +## Outcome & current state (as of 2026-07-12) + +**Open-app model.** `supacode/Domain/OpenWorktreeAction.swift` is a 39-case enum +covering editors, terminals, git clients, Finder, Xcode, and `$EDITOR`. Ordering lives +in `editorPriority` / `terminalPriority` / `gitClientPriority`, composed into +`defaultPriority` (resolution order) and `menuOrder` (dropdown order, filtered by +`isInstalled`). JetBrains-family apps (androidStudio, clion, goland, intellij, +intellijEAP, phpstorm, pycharm, rider, rubymine, rustrover, webstorm) open via +`NSWorkspace.OpenConfiguration.arguments`; the rest via +`open(_:withApplicationAt:configuration:)`. Menu icons are pre-resized to 16×16 and +cached in a `@MainActor` hit-only cache keyed by bundle identifier. + +**Project detection.** `supacode/Domain/WorktreeProjectKind.swift` defines 11 kinds +with `detect(at:fileManager:)` (single shallow listing, most-specific marker first, +`package.json` last) and `preferredActions` (apple → Xcode; android → Android Studio, +IntelliJ, IDEA EAP; dotnet → Rider; java → IntelliJ, IDEA EAP; golang → GoLand; rust → +RustRover; cpp → CLion; php → PhpStorm; ruby → RubyMine; python → PyCharm; web → +WebStorm). `OpenWorktreeAction.preferredDefault(for:isInstalled:)` prepends these to +`defaultPriority`; final fallback is Finder. + +**Reducer wiring.** `fromSettingsID(_:defaultEditorID:workingDirectory:)` is called +from three sites: `supacode/Features/App/Reducer/AppFeature+Support.swift` (shared +helper used by `worktreeSettingsLoaded` and the Canvas focus path) and two sites in +`supacode/Features/App/Reducer/AppFeature.swift` (including `settingsChanged`). +`openActionResetToAutomatic` clears a pinned per-repo selection; the toolbar UI lives in +`supacode/Features/Repositories/Views/WorktreeDetailView.swift`, +`WorktreeDetailToolbarViews.swift`, and `OpenWorktreeActionMenuLabelView.swift` (no +per-render icon resize). + +**Code host.** `supacode/Clients/Git/GitRemoteWebInfo.swift` and +`GitClient.parseRepositoryWebInfo` in `supacode/Clients/Git/GitClient.swift` handle +GitHub/GitLab/SSH-with-port remote shapes; `Repository.Capabilities.supportsCodeHost` +(`supacode/Domain/Repository.swift`) gates the action; the open-PR-or-homepage fallback +is in `supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift` +via `supacode/Clients/Workspace/OpenURLClient.swift`. + +**Tests.** `supacodeTests/WorktreeProjectKindTests.swift` (marker→kind matrix, +precedence, nil cases against real temp directories), +`supacodeTests/OpenWorktreeActionTests.swift` (bundle IDs, menu order covers all cases, +heuristic resolution with injected `isInstalled`), +`supacodeTests/AppFeatureDefaultEditorTests.swift` (end-to-end reducer resolution), +`supacodeTests/GitRemoteInfoTests.swift` (remote parsing). + +**Docs.** Behavior documented in `docs/components/repositories-and-worktrees.md` +(Automatic selection, pinning, detection) and `docs/reference/settings-fields.md` +(`defaultEditorID` / `openActionID`, `auto` semantics). + +## Deviations from plan + +None known. Note for provenance: many editor cases (Windsurf, VSCodium, Antigravity, +VS Code Insiders, Warp, WebStorm, PyCharm, IntelliJ, RustRover) predate this entry — +they were inherited from upstream before/alongside the fork and are not part of this +entry's PRs. + +## Open questions + +- `WorktreeProjectKind` classifies any Gradle project as `android`, so a pure JVM + Gradle project on a machine with Android Studio installed resolves to Android Studio + rather than IntelliJ. The fallback chain covers machines without Android Studio, but + the kind name and mapping conflate "Gradle" with "Android" by design. diff --git a/docs-ai/040-automatic-open-in/002-upstream-editor-ports.md b/docs-ai/040-automatic-open-in/002-upstream-editor-ports.md new file mode 100644 index 00000000..ad4b8fb8 --- /dev/null +++ b/docs-ai/040-automatic-open-in/002-upstream-editor-ports.md @@ -0,0 +1,50 @@ +# 040 — Amendment: Upstream Editor Ports (Zed Preview, IDEA EAP, Nova) + +## Context + +The 2026-07-09 upstream review batch (post-v0.10.5, see +[017-upstream-sync-process](../017-upstream-sync-process/000-plan.md)) found six recent +editor additions upstream. Three were already present in the fork with identical bundle +ids — GoLand / Rider / PhpStorm landed via `e423d2d8` (#439) on 2026-06-13. The +remaining three were ported as PR #542 (merged 2026-07-08, commit `9a13c42d`). + +## Change + +| Editor | Upstream | Bundle id | Placement | +| --- | --- | --- | --- | +| Zed Preview | upstream #447 | `dev.zed.Zed-Preview` | right after Zed in `editorPriority` (channel-variant convention) | +| IntelliJ IDEA EAP | upstream #496 | `com.jetbrains.intellij-EAP` | right after IntelliJ; JetBrains CLI-args open path | +| Nova | upstream #506 | `com.panic.Nova` | after Sublime Text among Mac-native generic editors | + +Fork-specific integration: IDEA EAP participates in the fork's project-type detection — +`WorktreeProjectKind.preferredActions` for `android` and `java` ends with IDEA EAP as +the IntelliJ fallback — which upstream does not have. + +Decisions carried from the sync batch: + +- Upstream's `c38c325d` #423 OpenTarget/OpenBehavior refactor was deliberately NOT + adopted; the three editors are additive cases in the fork's existing + `OpenWorktreeAction` enum shape. +- `settingsID` raw values match upstream exactly (`zed-preview`, `intellijEAP`, `nova`) + so persisted selections stay portable across future syncs; `intellijEAP` breaks the + fork's kebab-case convention on purpose. + +Tests added: bundle-id assertions, `editorPriority` membership, +`channelVariantsFollowTheirStableEditors` (pins Zed Preview / IDEA EAP directly after +their stable channels), updated android/java `preferredActions` expectations, and +`preferredDefaultPicksIntellijEAPWhenOnlyEAPInstalled` for Automatic-mode fallback. +`docs/components/repositories-and-worktrees.md` was updated in the same change. + +## Refs + +- PR #542 (merged 2026-07-08) +- Upstream #447 / #496 / #506; ledger entry: 2026-07-09 batch in + `../017-upstream-sync-process/upstream-ledger.md` + +## Current state + +Verified in `supacode/Domain/OpenWorktreeAction.swift`: `zedPreview` follows `zed` and +`intellijEAP` follows `intellij` in `editorPriority`; `nova` sits after `sublimeText`; +all three bundle ids and `settingsID` literals match the table above. IDEA EAP is +present in `supacode/Domain/WorktreeProjectKind.swift` `preferredActions` for `android` +and `java`. diff --git a/docs-ai/041-ghosttykit-prebuilt-artifacts/000-plan.md b/docs-ai/041-ghosttykit-prebuilt-artifacts/000-plan.md new file mode 100644 index 00000000..067a5e97 --- /dev/null +++ b/docs-ai/041-ghosttykit-prebuilt-artifacts/000-plan.md @@ -0,0 +1,100 @@ +# 041 — GhosttyKit Prebuilt Artifacts: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-14 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #450 | +| **Sources** | `doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md` (absorbed here; original removed in the docs-ai migration), PR #450 description | +| **Related** | [016-dev-build-and-ci-workflow](../016-dev-build-and-ci-workflow/000-plan.md), [007-ghostty-embedding-integration](../007-ghostty-embedding-integration/000-plan.md), [ghostty-fork-sync runbook](../007-ghostty-embedding-integration/ghostty-fork-sync.md) | + +## Background + +Prowl links `Frameworks/GhosttyKit.xcframework` directly from the Xcode project and +bundles `Resources/ghostty` + `Resources/terminfo`, all generated from the +`ThirdParty/ghostty` submodule (pinned to the `onevcat/ghostty` fork) via +`zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false`. That Zig build +is the expensive step: cold worktrees lack the generated framework/resources, so a fresh +`make build-app` triggered a full Ghostty source build. It also requires a full Zig +toolchain via mise, and — a real constraint on this machine — the Ghostty Zig source only +links with the Xcode 26.3 toolchain (`DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/...`); +newer Xcode versions fail at link time. Entry 016 had already added SHA-based skip logic +(`.ghostty_hash` / `.ghostty_build_stamp`), but a cache miss still meant compiling Ghostty. + +The plan: make prebuilt, checksummed GhosttyKit artifacts the default acquisition path, +keeping the local source build as an explicit maintenance operation and emergency +fallback. + +## Goals + +- Publish per-commit artifact sets as `onevcat/ghostty` GitHub Releases, tagged + `xcframework-<ghostty_commit_sha>-prowl-v1`, with two assets: + `GhosttyKit.xcframework.tar.gz` and `GhosttyKit-resources.tar.gz` (containing exactly + `ghostty/` and `terminfo/`). +- Key artifacts by the Ghostty **gitlink** recorded in Prowl + (`git rev-parse HEAD:ThirdParty/ghostty`), not the submodule working tree — so cold + worktrees can download artifacts before the heavy submodule is even initialized. +- Store a reviewed SHA256 manifest in-repo (`scripts/ghosttykit-checksums.txt`, one line + per pinned commit: `<ghostty_sha> <xcframework_sha256> <resources_sha256>`) as the + source of truth for artifact integrity. +- `make ensure-ghostty` prefers download+verify+install; falls back to the local Zig build + when no artifact is pinned or the download is unavailable. +- Checksum mismatch or unsafe archive shape is a **hard failure**, never a silent + fallback — that signals a broken or tampered artifact. +- CI (`.github/actions/setup-macos`) keys its cache on the pinned gitlink and runs + `make ensure-ghostty` on cache miss, making misses much faster. + +### Non-goals + +- No SwiftPM binary target migration — the app target links the xcframework directly and + bundles resources separately; a Makefile downloader matches that shape with less churn. +- No dependency on upstream `ghostty-org/ghostty` release assets. +- No "latest release" behavior — only the exact pinned commit's artifact is ever used. +- No committing of generated `GhosttyKit.xcframework` or runtime resources. + +## Design / Approach + +Four scripts plus Makefile/CI wiring: + +- `scripts/ensure-ghosttykit-artifacts.sh` — the downloader. Reads the pinned gitlink; + fast-exits when artifacts exist and `.ghostty_hash` matches; otherwise looks up the SHA + in the checksum manifest, downloads both release assets, verifies SHA256, validates + archive shape, extracts into `Frameworks/` and `Resources/`, refreshes `libghostty.a`'s + archive index with `xcrun ranlib`, and writes the `.ghostty_hash` / + `.ghostty_build_stamp` markers. Exit code contract: `0` = installed/up-to-date, `2` = + fall back to local build, anything else = hard failure. +- `scripts/validate-ghosttykit-artifacts.py` — tar allowlist validator: rejects absolute + paths, `..` traversal, unsafe link targets, unexpected roots, and non-file/dir/link + members; requires the expected roots to be present. +- `scripts/package-ghosttykit-artifacts.sh` — packages the current `Frameworks/` + + `Resources/` outputs into the two tarballs, validates them, and prints the release tag + plus the ready-to-paste manifest line. +- `scripts/ghosttykit-checksums.txt` — the reviewed manifest. +- `Makefile ensure-ghostty` — runs the downloader; on exit 2 rebuilds via + `make -B build-ghostty-xcframework` and clears Xcode DerivedData when the pinned SHA + changed (preserving 016's stale-module-cache behavior). `make sync-ghostty` stays the + explicit "force local rebuild from source" command. + +**Publishing flow** (fork maintenance, per new Ghostty commit): build locally with +`DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty`, run +the packager, create the matching `onevcat/ghostty` release with both assets, append the +emitted line to the manifest, then verify a clean acquisition +(`rm -rf` artifacts + markers → `make ensure-ghostty` → `make build-app`). The Xcode 26.3 +pin applies only to building the Ghostty Zig source; Prowl itself builds with the current +Xcode. The operational steps live in the +[ghostty-fork-sync runbook](../007-ghostty-embedding-integration/ghostty-fork-sync.md). + +## Alternatives & decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Distribution shape | Makefile downloader | SwiftPM binary target would churn the project for no gain given direct xcframework linking + separate resource bundling | +| Artifact key | Pinned gitlink, not submodule working tree | Works in cold worktrees before submodule init | +| Integrity | In-repo reviewed SHA256 manifest + tar shape validator | Pinned tags alone don't protect against replaced assets; unsafe extraction is a real tar risk | +| Failure policy | Missing artifact → fallback; checksum/shape failure → hard error | Absence is expected for new commits; mismatch means something is wrong | +| Fallback | Keep local Zig source build | Fork maintenance and new-commit development must not be blocked | + +## Amendments + +(none) diff --git a/docs-ai/041-ghosttykit-prebuilt-artifacts/001-action.md b/docs-ai/041-ghosttykit-prebuilt-artifacts/001-action.md new file mode 100644 index 00000000..5b1a91f6 --- /dev/null +++ b/docs-ai/041-ghosttykit-prebuilt-artifacts/001-action.md @@ -0,0 +1,63 @@ +# 041 — GhosttyKit Prebuilt Artifacts: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-14 | Downloader, validator, packager, checksum manifest, `ensure-ghostty` rewiring, CI setup action update, ghostty fork-sync runbook update; first artifact set published and pinned for Ghostty commit `48365577` | PR #450 (commit `a415d35e`) | + +Everything landed in a single commit. Verification recorded in the PR: fallback exit code +`2` with no pinned artifact and with a missing release repository, validator +accept/reject fixtures, a full `sync-ghostty` → package → release → clean +`make ensure-ghostty` round trip against the published release +`xcframework-48365577c1ae8e422c0dd90489921f07b9f79171-prowl-v1`, plus `make check` and +`make build-app`. + +## Outcome & current state (as of 2026-07-12) + +- `scripts/ensure-ghosttykit-artifacts.sh` — downloader with the exit-code contract + (0 = done, 2 = fall back, else hard fail). Unchanged since PR #450. Environment + overrides exist beyond the plan: `PROWL_GHOSTTY_ARTIFACT_REPOSITORY`, + `PROWL_GHOSTTY_ARTIFACT_FLAVOR`, `PROWL_GHOSTTY_CHECKSUMS_FILE`, + `PROWL_GHOSTTY_ARTIFACT_VALIDATOR`, and `PROWL_GHOSTTY_NO_PREBUILT=1` (force local + build). Downloads use `curl --retry 3` with timeouts; both the prebuilt path (script) + and the fallback path (Makefile) clear `~/Library/Developer/Xcode/DerivedData/supacode-*` + when the pinned SHA changed. +- `scripts/validate-ghosttykit-artifacts.py` — tar allowlist validator, roots + `GhosttyKit.xcframework` / `ghostty` + `terminfo`. +- `scripts/package-ghosttykit-artifacts.sh` — packages from `Frameworks/` and + `Resources/` with `COPYFILE_DISABLE=1`, self-validates, prints tag + manifest line. + Output dir `dist/ghosttykit` (overridable via `PROWL_GHOSTTY_ARTIFACT_DIST_DIR`). +- `scripts/ghosttykit-checksums.txt` — one entry, `48365577c1ae8e422c0dd90489921f07b9f79171`, + which still equals the current gitlink (`git rev-parse HEAD:ThirdParty/ghostty`); the + submodule has not been bumped since, so the pinned commit is fully covered. +- `Makefile` — `ensure-ghostty` runs the downloader first and interprets exit 2 as + "build locally via `make -B build-ghostty-xcframework`"; it is a prerequisite of + `build-app`, `test`, and `test-app`. `sync-ghostty` remains the explicit force-rebuild. + Release-shaped targets (`install-release`, `archive`) still depend on + `build-ghostty-xcframework` (source build path), not `ensure-ghostty`. +- `.github/actions/setup-macos/action.yml` — computes `GHOSTTY_SHA` from the gitlink, + caches `Frameworks/GhosttyKit.xcframework`, `Resources/ghostty`, `Resources/terminfo` + and both marker files under key `…-ghostty-v1-$GHOSTTY_SHA`, runs `make ensure-ghostty` + only on cache miss, then unconditionally rewrites the marker files to match the pinned + SHA. +- Operational publishing steps (tag format, packaging, manifest update, clean-path + verification, the Xcode 26.3 `DEVELOPER_DIR` pin for Zig builds) are maintained in the + living [ghostty-fork-sync runbook](../007-ghostty-embedding-integration/ghostty-fork-sync.md). + +## Deviations from plan + +- The environment-variable overrides (repository/flavor/manifest/validator paths, + `PROWL_GHOSTTY_NO_PREBUILT`) were not in the plan; they were added for testability + (negative download tests against a missing repository) and as an operator escape hatch. +- Otherwise the implementation checklist in the plan was completed as written. + +## Open questions + +- `archive` and `install-release` bypass the prebuilt path by depending on + `build-ghostty-xcframework` directly. Because that target is stamp-file-gated, a + release build on a machine that acquired artifacts via `ensure-ghostty` reuses them + (the downloader writes `.ghostty_build_stamp`), which appears intentional — but it + means release builds are only source-built when no stamp exists, and then require the + Xcode 26.3 toolchain. Not verified whether this asymmetry is deliberate policy or just + historical wiring. diff --git a/docs-ai/042-project-workspaces/000-plan.md b/docs-ai/042-project-workspaces/000-plan.md new file mode 100644 index 00000000..88b1079f --- /dev/null +++ b/docs-ai/042-project-workspaces/000-plan.md @@ -0,0 +1,108 @@ +# 042 — Project Workspaces: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-17 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #455, #472, #481, #485 | +| **Sources** | PR #455/#472/#481/#485 descriptions, `codex/workspace-mode` / `fix/workspace-mode-review` branch commit history | +| **Related** | [010-plain-folder-support](../010-plain-folder-support/000-plan.md), [013-prowl-cli](../013-prowl-cli/000-plan.md), [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md), `docs/components/workspaces.md` | + +## Background + +Agents increasingly needed to work on tasks that span several repositories (an app, its +API, a shared package). Prowl's runnable targets were either a single git repository (with +worktrees) or a single plain folder (010-plain-folder-support); there was no entry that +gives one agent a shared working directory covering multiple repositories at once. The +feature was contributed by MikotoZero (#455, 22 commits) and landed through an onevcat +review pass (#472). + +## Goals + +- One runnable target that covers multiple repositories: the terminal starts in a shared + workspace root containing a materialized copy/checkout of each member repository. +- Metadata-driven and inspectable: `.prowl/workspace.json` records the member + repositories, their source, relative path, branch, and base ref. +- Create workspaces from mixed sources: already-opened repositories, local repository + folders, bare repositories, and remote URLs. +- Explicit per-repository checkout semantics: **Link** (symlink-style reuse of an existing + folder), **Create Branch** (new worktree/branch), **Use Existing** (check out an + existing ref), including converting remote refs into local tracking branches. +- Roll back all intermediate products (clones, worktrees, the workspace folder) when + creation fails or is canceled. +- Sidebar/detail integration: the workspace is a selectable runnable folder; its child + repositories appear as read-only rows with live branch, diff, and PR status. +- A dedicated removal flow: remove from Prowl only, or additionally clean up the workspace + folder and worktrees, with per-repository branch deletion as an opt-in. +- Safety on cleanup: never delete files by default, never delete protected branches, + re-confirm when `git worktree remove`/unregister fails rather than leaving dangling + registrations. +- CLI visibility: `prowl list` distinguishes `workspace` from `git`/`plain` targets. + +### Non-goals + +- A workspace is deliberately **not** a git repository. Worktree, branch, diff, and PR + controls stay per-repository features; child repositories are metadata entries, not + tracked worktrees. + +## Design / Approach + +Reconstructed from the PR descriptions and the landed code. + +**Domain model.** `ProjectWorkspace` (`supacode/Domain/ProjectWorkspace.swift`) owns the +whole lifecycle: the `prowl.workspace.v1` snake_case JSON schema at +`.prowl/workspace.json` (`title`, `description`, `task_links`, `repositories` with +`source_kind` / `checkout` info), path normalization (`normalized(relativeTo:)`), source +kinds (`remote`, `local_repository`, `bare_repository`, `existing_path`), checkout modes +(`link`, `create_branch`, `use_existing_ref` plus remote-ref→tracking-branch), and +`create(...)` with a `MaterializationLedger` that records every produced artifact so +failure/cancel can roll back. New workspace folders default to +`~/.prowl/workspaces/<name>` (`SupacodePaths.workspacesDirectory`). + +**Reuse of plain-folder support.** Rather than a third `Repository.Kind`, `Repository` +gains a `workspace: ProjectWorkspace?` payload and its initializer forces `kind = .plain` +whenever it is present — a workspace is a plain runnable folder with metadata, and 010's +capability gating (repository-level selection, no git capabilities) applies unchanged. + +**Creation flow.** `WorkspaceCreationPromptFeature` + `WorkspaceCreationPromptView` drive +a multi-row prompt (Add Opened / Add Remote / Add Local), remote-head loading for URL +sources, and per-row branch/base-ref pickers; `RepositoriesFeature+WorkspaceCreation.swift` +executes creation off the reducer. + +**Child status.** `RepositoriesFeature+WorkspaceChildren.swift` refreshes each child's +branch, line changes, and (when GitHub integration is available) PR status on the +`repositoriesLoaded` cadence. Children are deliberately not fed through the worktree info +watcher, which only handles tracked worktrees. + +**CLI.** `ListCommandPayload` gains a `workspace` kind, mapped in +`ListRuntimeSnapshotBuilder`, so agents can tell workspaces apart in `prowl list`. + +## Alternatives & decisions + +- **Standalone "New Workspace" toolbar button (later superseded).** During #455 the button + was folded into the Add Repository menu to reduce toolbar overflow, then reverted to a + standalone button once the sidebar-toolbar disappearance glitch was identified as a + macOS `NavigationSplitView` sidebar `.toolbar` lifecycle bug unrelated to button count. + The PR explicitly refused to "hide the button" as a fake fix and noted the real escape + is moving actions out of the sidebar toolbar. Two weeks later #520 (entry 019) replaced + both buttons with a single "Add..." popover, which is the current UI. +- **Plain-kind piggyback over a new repository kind.** Chosen so all existing plain-folder + behavior (selection, terminal keying, capability gating) applies for free; git + capabilities stay per-repository by construction. +- **Children as metadata, not worktrees.** Rejecting synthetic worktrees keeps the + worktree pipeline honest; the cost is a separate, coarser refresh path for child rows. +- **Bare repositories: domain yes, UI no.** The metadata/materialization layer supports + `bare_repository`, but the creation menu only exposes Opened/Remote/Local sources. +- **Review-wave corrections (#472)** rather than post-merge fixes: workspace removal now + participates in next-repository auto-selection; local branches named `*/HEAD` are no + longer filtered out of ref options; the persistence normalizer uses a lightweight + `hasMetadata` file check instead of decoding the full workspace JSON per entry; + independent git lookups during base-ref fetch run concurrently (`async let`); workspace + root-path logic is deduplicated into `ProjectWorkspace`. + +## Amendments + +- Updated 2026-06-20: sidebar/toolbar follow-up fixes — toolbar title width for + folder/workspace (#481), full-row click area and collapse/expand-all for workspaces + (#485) — see [002-sidebar-and-toolbar-follow-ups.md](002-sidebar-and-toolbar-follow-ups.md) diff --git a/docs-ai/042-project-workspaces/001-action.md b/docs-ai/042-project-workspaces/001-action.md new file mode 100644 index 00000000..7ec89921 --- /dev/null +++ b/docs-ai/042-project-workspaces/001-action.md @@ -0,0 +1,65 @@ +# 042 — Project Workspaces: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-11 → 06-18 | Feature built on `codex/workspace-mode` by MikotoZero (22 commits): `ProjectWorkspace` domain model + `.prowl/workspace.json`, creation flow with checkout modes and rollback, sidebar/detail UI with read-only child rows, removal flow with cleanup guards, `prowl list` workspace kind, `docs/components/workspaces.md`, ~2k lines of tests | PR #455 | +| 2026-06-18 | Review pass on `fix/workspace-mode-review` (onevcat, 5 fix commits + child-row icon tweaks): removal auto-selection, `*/HEAD` local-branch filter fix, `hasMetadata` instead of full JSON decode in the persistence normalizer, `async let` parallel git lookups in base-ref fetch, root-path logic deduplicated into `ProjectWorkspace`. Merged to `main` carrying #455's commits (`161237fb`); GitHub marked both PRs merged at the same instant | PR #472 | +| 2026-06-20 | Toolbar title too narrow for folder/workspace names | PR #481 → [002](002-sidebar-and-toolbar-follow-ups.md) | +| 2026-06-20 | Workspace sidebar full-row click area + collapse/expand all | PR #485 → [002](002-sidebar-and-toolbar-follow-ups.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Domain/ProjectWorkspace.swift` (~1,000 lines) — schema + `prowl.workspace.v1`, `metadataURL`/`hasMetadata`/`load`/`normalized(relativeTo:)`, + `create(...)` with `MaterializationLedger` rollback, `removeWorktrees` / + `removeWorkspaceFolder`, `workspaceRootPath` / `uniqueWorkspaceRootPath` (defaults + under `~/.prowl/workspaces`, `SupacodePaths.workspacesDirectory` in + `supacode/Support/SupacodePaths.swift`). +- `supacode/Domain/Repository.swift` — `workspace: ProjectWorkspace?`, `isWorkspace`; + the initializer forces `kind = .plain` when a workspace payload is present. +- Reducers — `supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift` + (creation prompt state machine), `RepositoriesFeature+WorkspaceCreation.swift` + (execution + rollback), `RepositoriesFeature+WorkspaceChildren.swift` + (`ResolvedWorkspaceChild`, branch/diff/PR refresh on the `repositoriesLoaded` cadence, + explicitly kept out of the worktree info watcher), removal handling in + `RepositoriesFeature+RepositoryManagement.swift`. +- Views — `supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift` + (Add Opened / Add Remote / Add Local menu; bare-repository rows render but are not + offered as a source), `WorkspaceDetailView.swift`, `WorkspaceChildRowsView.swift`, + `WorkspaceRepositoriesGridView.swift`, `RemoveWorkspaceConfirmationView.swift`; + `SidebarListView.swift` and `WorktreeDetailTitleView.swift` carry the 002 fixes. +- Entry points — "New Workspace" lives in the Worktrees menu + (`supacode/Commands/WorktreeCommands.swift`) and the command palette + (`CommandPaletteFeature` / `CommandPaletteItem.newWorkspace`). The standalone sidebar + toolbar button that #455 deliberately kept **no longer exists**: since #520 (entry 019) + the sidebar toolbar has a single "Add..." button opening the `AddToProwlView` popover, + which contains "Add Workspace". +- Settings — `supacode/Features/Settings/Views/RepositorySettingsView.swift` shows the + workspace metadata read-only (description, task links, repository grid). +- Persistence — `supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift` + uses `ProjectWorkspace.hasMetadata` (file-exists check) when normalizing entries. +- CLI — `supacode/CLIService/Shared/ListCommandPayload.swift` (`case workspace`) and + `supacode/CLIService/ListRuntimeSnapshotBuilder.swift`; documented in + `docs/components/cli.md` (`kind` is `git`|`plain`|`workspace`). +- Tests — `supacodeTests/ProjectWorkspaceTests.swift` (~950 lines of domain tests), + `RepositoriesFeatureTests.swift` (incl. `removeSelectedWorkspaceSelectsNextRepository`), + `GitClientBranchRefsTests.swift` (incl. `branchRefOptionsPreservesLocalBranchNamedHEAD`), + `DetailToolbarTitleTests.swift`. +- User-facing behavior is documented in `docs/components/workspaces.md` (added in the + same merge). + +## Deviations from plan + +- The standalone "New Workspace" toolbar button — argued for explicitly in #455's + description — was superseded on 2026-06-30 by the consolidated Add-to-Prowl popover + (#520, entry 019). The direction matches #455's own analysis (reduce reliance on + `NavigationSplitView` sidebar toolbar items), so this is evolution rather than + reversal; the menu and palette entry points survive as planned. + +## Open questions + +- None beyond the note recorded in + [002-sidebar-and-toolbar-follow-ups.md](002-sidebar-and-toolbar-follow-ups.md) about + `onTapGesture` vs the project's Button-preference guideline. diff --git a/docs-ai/042-project-workspaces/002-sidebar-and-toolbar-follow-ups.md b/docs-ai/042-project-workspaces/002-sidebar-and-toolbar-follow-ups.md new file mode 100644 index 00000000..a6e47999 --- /dev/null +++ b/docs-ai/042-project-workspaces/002-sidebar-and-toolbar-follow-ups.md @@ -0,0 +1,39 @@ +# 042 — Amendment: Sidebar and Toolbar Follow-up Fixes (2026-06-20) + +## Context + +Two days of dogfooding after the workspace merge surfaced small UI defects in how +workspaces (and plain folders) integrate with existing chrome. + +## Change + +- **Toolbar title width (#481).** Folder and workspace names in the toolbar navigation + area rendered narrower than branch names: branch titles were wrapped in a `Button` + (for rename) and got toolbar button padding, while folder/workspace titles were bare + labels. Fix: wrap folder/workspace titles in a no-op `Button` so all title kinds get + the same toolbar styling (`supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift`). +- **Workspace child row click area (#485).** Only the label text of a workspace child row + was clickable. The row's `Button(.plain)` was replaced with `onTapGesture` + + `contentShape(.interaction, .rect)` to match the worktree-row pattern + (`supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift`). +- **Collapse/expand all (#485).** The sidebar header's Collapse all / Expand all toggle + ignored workspaces because `expandableRepositoryIDs` filtered on `supportsWorktrees` + only; the filter now also accepts `isWorkspace` + (`supacode/Features/Repositories/Views/SidebarListView.swift`). + +## Refs + +- PR #481 (merged 2026-06-20, `d10572e3`) +- PR #485 (merged 2026-06-20, `0a49b5e5`) + +## Current state + +All three fixes are verified in the working tree: `WorktreeDetailTitleView.swift` keeps +the no-op `Button` wrapper (with an explanatory comment), `WorkspaceChildRowsView.swift` +uses `contentShape(.interaction, .rect)` + `onTapGesture`, and +`SidebarListView.expandableRepositoryIDs` filters on +`$0.capabilities.supportsWorktrees || $0.isWorkspace`. + +Note: #485 trades the project guideline "prefer `Button` over `onTapGesture`" for +consistency with the existing worktree-row implementation; if worktree rows ever move +back to `Button`, workspace child rows should follow. diff --git a/docs-ai/043-canvas-tile-layout/000-plan.md b/docs-ai/043-canvas-tile-layout/000-plan.md new file mode 100644 index 00000000..0cc563b0 --- /dev/null +++ b/docs-ai/043-canvas-tile-layout/000-plan.md @@ -0,0 +1,115 @@ +# 043 — Canvas Tile Layout: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-24 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #502, #504 | +| **Sources** | `doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md` (absorbed here; original removed in the docs-ai migration), PR descriptions | +| **Related** | [005-canvas-live-sessions](../005-canvas-live-sessions/000-plan.md), [024-canvas-interaction-evolution](../024-canvas-interaction-evolution/000-plan.md), `docs/components/canvas.md`, `docs/reference/settings-fields.md` | + +## Background + +Canvas ([005](../005-canvas-live-sessions/000-plan.md)) had two auto-layouts, both +triggered from the toolbar, keyboard shortcuts, and the command palette +([024](../024-canvas-interaction-evolution/000-plan.md) added the shortcut/palette +wiring): + +| Mode | Shortcut | Card size | Algorithm | +| --- | --- | --- | --- | +| **Organize** | ⌘⌥G | uniform default size (`adaptiveDefaultCardSize`) | √N balanced grid | +| **Arrange** | ⌘⌥R | preserves each card's current size | `CanvasCardPacker` hybrid bin-packing | + +Both place cards in the infinite canvas coordinate space and rely on +`fitToView(canvasSize:)` to scale/center the group into the viewport. Neither fills +the screen: Organize uses a fixed card size, Arrange keeps whatever sizes cards have, +so with a handful of cards much of the viewport is empty. For the "watch all agents at +a glance" use case, users wanted an automatic-window-manager-style layout that gives +every card as much area as possible. + +## Goals + +- A third layout, **Tile** (⌘⌥T, toolbar icon `rectangle.split.2x1`), that **resizes + every card** so the set tiles and fills the visible canvas. +- Balanced grid whose orientation follows the window: `s = max(1, floor(√N))` lines on + the short axis; wide window → lines are rows, tall window → lines are columns + (a pure transposition). Extra cards go to the later lines + (`lineCounts`: first `s - rem` lines get `base`, last `rem` lines get `base + 1`). +- Each line independently fills its full extent — a 2-card row gets ½-width cards, a + 3-card row ⅓-width — so card sizes may differ between lines by design. +- All three trigger paths (toolbar button, shortcut, command palette) behave + identically, reusing the arrange/organize infrastructure; the shortcut is + rebindable/disableable in Settings. +- Readability at higher card counts: adaptive zoom so surfaces keep a comfortable + terminal grid instead of showing huge text in tiny cards (v2 of the plan, added in + response to "text too large, gaps too wide" feedback on the initial fixed-scale cut). + +**Non-goals**: aspect-aware line-count tuning for extreme ratios (e.g. a 32:9 screen +still tiles 4 cards as 2×2, not 1×4) — noted as a possible later enhancement via +candidate scoring in `lineCounts`, deliberately out of scope to keep the deterministic +balanced-grid shape. + +## Design / Approach + +- **Pure layout core** — `CanvasTileLayout` in + `supacode/Features/Canvas/Models/CanvasCardLayout.swift`, next to + `CanvasCardPacker`: `static lineCounts(for:)` plus + `layout(keys:viewport:comfortableSize:)` returning `[String: CanvasCardLayout]`. + Testable without `@MainActor`. Row geometry divides the viewport minus spacing by + the line count; the title bar height is subtracted so the *visual* card (title bar + + terminal) tiles exactly. Empty keys or a degenerate viewport return an empty dict. +- **No min/max clamping**: tile card size is the frame divided by the grid; clamping + to `minCard*`/`maxCard*` would only create overlap in small windows. Those bounds + govern manual resize and default new-card sizing, not tiling. Small windows produce + small cards; visual scaling stays `fitToView`'s job. +- **Adaptive zoom** (v2): the grid is laid out in a `viewport × zoom` frame so + `fitToView` lands at `scale ≈ 1/zoom`. `zoom = 1` while tiled cards are at least + `comfortableSize` (`adaptiveDefaultCardSize × 0.6`) — a handful of cards keeps + native scale; beyond that, `zoom` grows so each surface keeps a readable terminal + (more rows/columns, smaller text). Spacing uses a tighter `tileCardSpacing` + (plan: 14 vs. the 20pt `cardSpacing`) living in the scaled frame, so the on-screen + gap (`spacing × scale`) also tightens as counts rise. `fitToView` clamps scale to + `[0.25, 1.0]`, so extreme counts degrade gracefully. +- **Triggers**: `tileCards()` / `tileCardsWithFit()` in + `supacode/Features/Canvas/Views/CanvasView.swift` mirroring the organize/arrange + pair; `.onKeyPress` handler; toolbar button; `CanvasCommandRequest.Command.tile` + handled in `CanvasView+Focus.swift`; `AppShortcuts.tileCanvasCards` + (`tile_canvas_cards`, ⌘⌥T — verified free among ⌘⌥ bindings) and the full + command-palette registration ("Tile Canvas Cards"). +- **Docs in the same PR**: `docs/components/canvas.md`, + `docs/reference/keyboard-shortcuts.md`. + +### Default-layout setting (follow-up, same day) + +Canvas's one-shot initial auto-layout (first entry per session, see 005) was hardcoded +to the size-preserving pack. #504 makes it configurable: a `CanvasDefaultLayout` enum +(`uniform` = same-size packed to fit, i.e. the previous behavior; `tile` = the new +layout), stored in `GlobalSettings` (`canvasDefaultLayout`), surfaced as a "Canvas +layout" picker in Settings → General → Default Views alongside "Launch in". **Default +is `tile`**, including for legacy `settings.json` without the key — an intentional +behavior change (the old default was never a promise). Saved card positions are still +restored regardless of the setting. + +## Alternatives & decisions + +- **Viewport-derived sizes vs. existing modes**: Organize fixes size, Arrange preserves + size; Tile derives size from the viewport — that inversion is the point of the third + mode rather than tweaking either existing one. +- **Binary orientation flip only** (`W ≥ H`): keeps the documented deterministic + shapes (2 → halves, 5 → 2+3, 9 → 3×3); aspect-aware `s` selection rejected for now. +- **Fixed scale = 1 rejected after review feedback**: replaced by the adaptive-zoom + frame; native scale is preserved for few cards, degradation is smooth for many. +- **Reuse `fitToView` instead of custom tile scaling**: the tile bounding box matches + the viewport aspect ratio, so the existing `min(W/bboxW, H/bboxH)` fit is exact. +- **Naming kept as Tile/Uniform** (#504): "Tile" matches the existing toolbar button + and docs; the size-preserving initial layout is called "Uniform" in Settings without + renaming any button. +- **Tile as the new default layout** (#504): judged the better default for most + fleets; legacy settings intentionally migrate to it via decode fallback. + +## Amendments + +- Updated 2026-06-27: visual tuning after real use — `tileCardSpacing` 14 → 12, + fit-to-view padding 30 → 12 (`viewportFitPadding`), `bottomToolbarReserve` 50 → 40 — + see [002-spacing-and-fit-margin-tweaks.md](002-spacing-and-fit-margin-tweaks.md) diff --git a/docs-ai/043-canvas-tile-layout/001-action.md b/docs-ai/043-canvas-tile-layout/001-action.md new file mode 100644 index 00000000..8d0a9d32 --- /dev/null +++ b/docs-ai/043-canvas-tile-layout/001-action.md @@ -0,0 +1,68 @@ +# 043 — Canvas Tile Layout: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-24 | Tile layout shipped: `CanvasTileLayout` (`lineCounts` + `layout`), `tileCards()`/`tileCardsWithFit()`, toolbar button (`rectangle.split.2x1`), ⌘⌥T key handler, `CanvasCommandRequest.tile`, full shortcut + command-palette wiring, `CanvasTileLayoutTests`, docs (`7c12d1fb`) | PR #502 | +| 2026-06-24 | Adaptive zoom added within the same PR: grid laid out in a `viewport × zoom` frame, `comfortableSize = adaptiveDefaultCardSize × 0.6`, tighter `tileCardSpacing = 14`; native/adaptive-zoom tests (`c751f8c9`) | PR #502 | +| 2026-06-24 | `canvasDefaultLayout` setting (Uniform / Tile, default `tile` incl. legacy decode fallback); "Default Views" pickers with descriptions in Settings → General; initial Canvas auto-layout branches on it (`7de630da`, copy simplification `dcf4c8dd`) | PR #504 | +| 2026-06-27 | Direct-to-main visual tuning: `tileCardSpacing` 14 → 10 → 12, fit padding 30 → `viewportFitPadding` 12, `bottomToolbarReserve` 50 → 40 (`2a40fa0a`, `abceefe1`) | [002](002-spacing-and-fit-margin-tweaks.md) | + +## Outcome & current state (as of 2026-07-12) + +- `supacode/Features/Canvas/Models/CanvasCardLayout.swift`: `CanvasTileLayout` + (`spacing`, `titleBarHeight`; `static lineCounts(for:)`; + `layout(keys:viewport:comfortableSize:)`), sitting next to `CanvasCardPacker` as + planned. No min/max clamping, empty result for empty keys or non-positive viewport. +- `supacode/Features/Canvas/Views/CanvasView.swift`: `tileCards()` derives + `comfortableSize` from `adaptiveDefaultCardSize × 0.6`; `tileCardsWithFit()` wraps it + with `cancelExpandForRelayout()` + `fitToView` in a 0.2s ease-in-out animation, + matching the arrange/organize pattern. Constants today: `tileCardSpacing = 12` + (vs. `cardSpacing = 20`), `viewportFitPadding = 12`, `bottomToolbarReserve = 40` + (post-[002](002-spacing-and-fit-margin-tweaks.md) values). The toolbar button uses + `rectangle.split.2x1`; the ⌘⌥T `.onKeyPress` handler resolves + `AppShortcuts.tileCanvasCards`. +- `fitToView` scale clamp `[0.25, 1.0]` lives in `CanvasViewportMath` + (`supacode/Features/Canvas/Views/CanvasSupportViews.swift`). +- Command path: `CanvasCommandRequest.Command.tile` in + `supacode/Features/Canvas/Models/CanvasFocusRequest.swift`, fulfilled in + `supacode/Features/Canvas/Views/CanvasView+Focus.swift`; `tile_canvas_cards` / + ⌘⌥T in `supacode/App/AppShortcuts.swift`; "Tile Canvas Cards" palette item + (`globalTileCanvasCards` in + `supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift` and the + related palette files); listed in + `supacode/Features/Settings/Views/ShortcutsSettingsView.swift`. +- Default layout: `supacode/Features/Settings/Models/CanvasDefaultLayout.swift` + (`uniform`/`tile` with titles and settings descriptions); + `GlobalSettings.canvasDefaultLayout` (default `.tile`, legacy fallback via + `decodeViewSettings`) in `supacode/Features/Settings/Models/GlobalSettings.swift`; + picker in `supacode/Features/Settings/Views/AppearanceSettingsView.swift` + ("Default Views" section of the General tab). `CanvasView`'s one-shot initial layout + switches `arrangeCards()` vs `tileCards()` on it; saved layouts still short-circuit + via `shouldAutoArrangeOnInitialEntry(for:)`. +- Tests: `supacodeTests/CanvasTileLayoutTests.swift` covers `lineCounts` (N = 1…10 and + sum invariant), wide/tall orientation flip, top-2/bottom-3 for N = 5, non-overlap, + full-width rows, native vs. adaptive zoom, and exact small-viewport tiling without + clamping. Settings coverage in `supacodeTests/SettingsFilePersistenceTests.swift` + (legacy payload → `.tile`) and `supacodeTests/SettingsFeatureTests.swift` + (binding persists to the settings file). +- User-facing docs: `docs/components/canvas.md` (⌘⌥T Tile Cards), + `docs/reference/keyboard-shortcuts.md` (`tile_canvas_cards`), + `docs/reference/settings-fields.md` and `docs/components/view-modes.md` + (`canvasDefaultLayout`). + +## Deviations from plan + +- Tuning constants moved after ship: `tileCardSpacing` 14 → 12, fit padding 30 → 12, + bottom reserve 50 → 40 ([002](002-spacing-and-fit-margin-tweaks.md)). The plan's + spacing-derivation reasoning (`14 × scale`) still holds with 12. +- An earlier draft of the plan's test list mentioned verifying min/max clamping at tiny + viewports, while the final algorithm section decided *against* clamping; the shipped + test asserts the opposite (`smallViewportTilesExactlyWithoutClamping`), consistent + with the no-clamp decision. +- Otherwise the implementation follows the plan's file-by-file change list closely. + +## Open questions + +- None. diff --git a/docs-ai/043-canvas-tile-layout/002-spacing-and-fit-margin-tweaks.md b/docs-ai/043-canvas-tile-layout/002-spacing-and-fit-margin-tweaks.md new file mode 100644 index 00000000..0f750290 --- /dev/null +++ b/docs-ai/043-canvas-tile-layout/002-spacing-and-fit-margin-tweaks.md @@ -0,0 +1,30 @@ +# 043 / 002 — Spacing and Fit-Margin Tweaks + +## Context + +After three days of real use with Tile as the default Canvas layout (#504), the tiled +view still wasted margin: the 30pt `fitToView` padding and 50pt bottom toolbar reserve +were sized for the free-form layouts, and the 14pt tile gap read wider than needed once +several cards were on screen. + +## Change + +Two direct-to-main commits on 2026-06-27 (no PR; released in v2026.6.27), both in +`supacode/Features/Canvas/Views/CanvasView.swift`: + +- `2a40fa0a` "tweak: tighten Canvas fit margins" — extracted the hardcoded 30pt + fit-to-view padding into a named `viewportFitPadding` constant set to **12**, reduced + `bottomToolbarReserve` 50 → **40**, and dropped `tileCardSpacing` 14 → 10. These fit + margins apply to `fitToView` generally, i.e. to all three layouts, not just Tile. +- `abceefe1` "tweak: adjust Canvas tile spacing" — partially reverted the gap: + `tileCardSpacing` 10 → **12**, the value in the tree today. + +## Refs + +- Commits `2a40fa0a`, `abceefe1` (main, 2026-06-27) + +## Current state + +`CanvasView` constants: `tileCardSpacing = 12`, `viewportFitPadding = 12`, +`bottomToolbarReserve = 40`, unchanged since. Layout algorithm and tests were not +affected (`CanvasTileLayout` takes spacing as a parameter). diff --git a/docs-ai/044-foundation-model-branch-names/000-plan.md b/docs-ai/044-foundation-model-branch-names/000-plan.md new file mode 100644 index 00000000..91d29ef9 --- /dev/null +++ b/docs-ai/044-foundation-model-branch-names/000-plan.md @@ -0,0 +1,97 @@ +# 044 — Foundation Model Branch Name Suggestions: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-06-27 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #518 | +| **Sources** | `doc-onevcat/plans/2026-06-27-foundation-model-branch-name.md`, PR #518 description | +| **Related** | [019-worktree-creation-and-lifecycle](../019-worktree-creation-and-lifecycle/000-plan.md), `docs/components/repositories-and-worktrees.md` | + +## Background + +Creating a new worktree requires typing a branch name (e.g. `feature/my-change`) in the +creation dialog — friction on every Cmd+N. macOS 26 ships the on-device +`FoundationModels` framework, which can generate a contextual name from signals Prowl +already has: existing branch naming conventions, sibling worktree branch names, terminal +pane titles (OSC-2), and active terminal content. When the model is unavailable (older +hardware) or the suggestion fails, the existing `WorktreeNameGenerator` +(adjective-animal-NNN, e.g. `bold-cat-042`) remains the fallback. + +A Phase 0 spike (standalone CLI tool, not kept in the repo) validated model quality and +latency first; the prefix-enforced prompt variant ("V3") performed best and gated the +go decision. Had the spike failed, only the random-name behavior would have shipped. + +## Goals + +- Auto-suggest a branch name in the worktree creation dialog, filled in asynchronously — + the dialog opens immediately and the user can start typing at once. +- Never overwrite user input; the suggestion is advisory. +- Fall back to a random adjective-animal-NNN name whenever the model is unavailable or + the suggestion fails validation. +- Abstract the LLM backend behind a protocol so future backends (stronger/remote models) + can slot in without touching business logic. + +### Non-goals + +- The non-prompt path (`promptForWorktreeCreation == false`) keeps `nameSource: .random` + unchanged — it exists for instant creation, and AI latency would violate that intent. +- Clipboard content is not used as a signal (macOS paste indicator + privacy concerns). + +## Design / Approach + +**LLM service layer.** `LLMService` protocol +(`supacode/Infrastructure/LLM/LLMService.swift`) with `isAvailable` and +`generate(prompt:)`. `FoundationModelLLMService` +(`supacode/Infrastructure/LLM/FoundationModelLLMService.swift`) wraps +`LanguageModelSession`, checks `SystemLanguageModel.default` availability, and applies a +3-second timeout. + +**TCA dependency.** `BranchNameSuggestionClient` +(`supacode/Clients/BranchNameSuggestion/BranchNameSuggestionClient.swift`) exposes +`gatherContext` (a `@MainActor` closure, wired in `supacode/App/supacodeApp.swift` +capturing `WorktreeTerminalManager`) and `suggest` (builds the prompt, calls the LLM, +sanitizes/validates, returns `nil` on any failure so the caller falls back). + +**Context gathering**, by signal priority: existing branch names (`baseRefOptions`, +first 10, for convention inference) → same-repo worktree branch names → terminal pane +titles → terminal active content via `readActiveContentsForCLI()` (per-pane cap +~300 chars, total budget ~1000 chars) → repository name. Worktrees are filtered to the +target repository, sorted selected-first then by a new +`WorktreeTerminalState.lastDefocusedAt` timestamp (set on focus-away in +`WorktreeTerminalManager`), and capped at 3. + +**Prompt (V3, prefix-enforced).** Detects `/`-prefixes from existing branches and +instructs the model it MUST use one; falls back to "descriptive kebab-case" wording for +fresh repos. Rules: single line, max 50 chars, no duplicate of an existing branch. + +**Sanitization & validation.** `BranchNameSanitizer` +(`supacode/Domain/BranchNameSanitizer.swift`): trim/lowercase, hyphenate whitespace and +underscores, strip invalid git-ref characters, collapse hyphen runs, truncate to 50. +Post-sanitization prefix enforcement: prepend the most common convention prefix from +existing branches, or `worktree/` when none. Validation returns `nil` (→ random +fallback) on duplicates (case-insensitive), length < 3 or > 50, or empty output. + +**Dialog integration** (`WorktreeCreationPromptFeature` / `WorktreeCreationPromptView`): +the dialog opens with an empty field whose placeholder is a pre-generated random name +(`randomPlaceholder`); `isSuggestingName` drives a subtle trailing `ProgressView`. When +the suggestion arrives (`branchNameSuggestionReceived`) it is shown as a dim +"Auto suggestion: {name}" hint line with a "Use" button (`useSuggestedBranchName`) — it +does not auto-fill the field. On submit, `effectiveBranchName` uses the typed name if +non-empty, otherwise the random placeholder; empty input no longer blocks creation. + +## Alternatives & decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Timing | Dialog opens immediately, name fills async | Don't block UI; user can start typing | +| Suggestion delivery | Opt-in hint + "Use" button, not auto-fill | Never overwrite user input (refined within the PR) | +| Fallback | Random adjective-animal-NNN | Uniform for prompt and non-prompt paths | +| Clipboard signal | Skipped entirely | macOS paste indicator + privacy | +| LLM layer | Protocol abstraction, Foundation Model default | Extensible without over-engineering | +| Spike gate | Ship random-name-only if spike failed | Avoid committing to insufficient model quality | + +## Amendments + +(none) diff --git a/docs-ai/044-foundation-model-branch-names/001-action.md b/docs-ai/044-foundation-model-branch-names/001-action.md new file mode 100644 index 00000000..617550b0 --- /dev/null +++ b/docs-ai/044-foundation-model-branch-names/001-action.md @@ -0,0 +1,70 @@ +# 044 — Foundation Model Branch Name Suggestions: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-06-27 | Core implementation: `LLMService` + `FoundationModelLLMService`, `BranchNameSuggestionClient`, `BranchNameSanitizer`, dialog integration, `lastDefocusedAt` tracking | PR #518 (`82055a63`) | +| 2026-06-27 | Refined within the PR: random placeholder as the default, AI suggestion demoted to an opt-in hint with "Use" button (no auto-fill); tests updated | PR #518 (`e929b62f`, `64e500a2`) | +| 2026-06-27 | Suggestion tooltip moved to a trailing `questionmark.circle` icon on the hint row | PR #518 (`ec26c5d4`, `bcd83977`) | +| 2026-06-27 | Merged | #518 (`7cb3dd75`) | + +## Outcome & current state (as of 2026-07-12) + +All pieces from the plan exist and are unchanged since the merge: + +- `supacode/Infrastructure/LLM/LLMService.swift` — `LLMService` protocol + (`isAvailable`, `generate(prompt:)`). +- `supacode/Infrastructure/LLM/FoundationModelLLMService.swift` — wraps + `LanguageModelSession`, availability via `SystemLanguageModel.default.isAvailable`, + 3-second timeout implemented with a racing task group. +- `supacode/Clients/BranchNameSuggestion/BranchNameSuggestionClient.swift` — + `BranchNameSuggestionContext` (+ `TerminalHint`), prefix-enforcing `buildPrompt`, + `live(llmService:)` validating output through `BranchNameSanitizer.validate`. +- `supacode/Domain/BranchNameSanitizer.swift` — `sanitize`, `detectConventionPrefix` + (fixed known-prefix list: `feature/`, `fix/`, `bugfix/`, `hotfix/`, `chore/`, + `refactor/`, `docs/`, `test/`, `ci/`), `ensurePrefix` (`worktree/` fallback), + `validate` (max length 50, min 3, duplicate rejection). +- `supacode/App/supacodeApp.swift` — `makeBranchNameSuggestionClient(terminalManager:)` + overrides `gatherContext`: same-repo filter, selected worktree first then + `lastDefocusedAt` descending, top 3, focused pane title + active content capped at + 300 chars per pane. +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — + `var lastDefocusedAt: Date?`, set on focus-away in + `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`. +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift` — + kicks off the suggestion effect from `promptedWorktreeCreationDataLoaded` + (cancellable via `CancelID.branchNameSuggestion`, cancelled on prompt dismissal); + the non-prompt path still uses `nameSource: .random`. +- `supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift` — + `isSuggestingName`, `suggestedBranchName`, `randomPlaceholder`, + `effectiveBranchName`; actions `branchNameSuggestionReceived` / + `useSuggestedBranchName`. +- `supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift` — placeholder + prompt text, mini `ProgressView` overlay while suggesting, hint row with "Use" button + and a help tooltip on a `questionmark.circle` icon. + +User-facing behavior is documented in `docs/components/repositories-and-worktrees.md` +(creation prompt section). + +## Deviations from plan + +- The plan sketched loading branch refs and requesting the suggestion "in parallel"; + the implementation starts the suggestion after the refs load (the context needs + `baseRefOptions`), so it runs sequentially after data load, still async to the dialog. +- `LLMService.isAvailable` is a synchronous property, not `get async` as sketched. +- The plan's ~1000-char total content budget across worktrees is not implemented; only + the 300-char per-pane cap and the 3-worktree limit exist. +- The plan document's own verification list (item 6: "non-prompt path → AI name is + used") contradicts its "Non-prompt path: No change" section; the implementation and + the PR test plan follow the latter (non-prompt stays random). + +## Open questions + +- `BranchNameSanitizer` has no dedicated unit tests despite being pure logic; only + reducer-level tests (`supacodeTests/RepositoriesFeatureTests.swift`, + `supacodeTests/WorktreeCreationPlacementTests.swift`) were touched in #518. +- `detectConventionPrefix` only recognizes its fixed prefix list, so repos using + variants like `feat/` get the `worktree/` fallback when the model output lacks a + slash, even though `buildPrompt` correctly advertises `feat/` to the model. Minor + inconsistency between the prompt-side and sanitizer-side prefix detection. diff --git a/docs-ai/045-native-agent-session-detection/000-plan.md b/docs-ai/045-native-agent-session-detection/000-plan.md new file mode 100644 index 00000000..434cc46b --- /dev/null +++ b/docs-ai/045-native-agent-session-detection/000-plan.md @@ -0,0 +1,127 @@ +# 045 — Native Agent Session Detection: Plan + +| | | +| --- | --- | +| **Status** | Implemented (retrospective) | +| **Anchor date** | 2026-07-12 | +| **Documented** | 2026-07-12 (backfilled) | +| **Primary PRs** | #556 | +| **Sources** | `doc-onevcat/agent-session-detection.md` (kept verbatim as [research-cli-session-identity.md](research-cli-session-identity.md) in the docs-ai migration), PR #556 description and branch commits, the 2026-07-11 12-CLI session-identity research (same doc) | +| **Related** | [030-agent-status-detection](../030-agent-status-detection/000-plan.md) (predecessor: agent identity + status), [013-prowl-cli](../013-prowl-cli/000-plan.md) / [013.002 `prowl agents`](../013-prowl-cli/002-agents-command.md) (consumer surface), [029-active-agents-panel](../029-active-agents-panel/000-plan.md), `docs/components/cli.md` | + +## Background + +Entry [030](../030-agent-status-detection/000-plan.md) taught Prowl *which* agent runs in +a pane and whether it is working/blocked/idle — observation from the process table and +the rendered screen. What no layer knew was the agent's **native session identity**: the +session id and transcript path that the agent CLI itself uses for resume, forking, and +history. That identity is the foundation for planned handoff/dispatch features (resume a +stopped agent, open/export the right transcript, restore sessions after an app restart, +pre-mint ids when Prowl spawns agents) — none of which can safely run on a guessed id. + +The work was preceded by a research pass (2026-07-11) over 12 agent CLIs — Codex, Claude +Code, Pi/OMP, Gemini, Cursor Agent, Cline, Copilot CLI, Kimi, Droid, OpenCode, Amp, Qwen +Code — covering five channels each: child-process environment variables, hooks, pre-mint +ids at spawn, headless resume, and on-disk storage encodings, verified against locally +installed CLIs where possible. The full findings are kept verbatim as +[research-cli-session-identity.md](research-cli-session-identity.md); this plan absorbs +its conclusions. + +## Goals + +- Resolve the exact agent process already selected by Active Agents to its native + session metadata — id, local transcript path, evidence source, confidence — with + **zero agent-side setup** (no hooks, no wrappers), same constraint as 030. +- Expose the result through `prowl agents` (optional `session` object in JSON, a + `session=` suffix in text mode) so automation and future features share one contract. +- **Never guess.** A wrong session id silently corrupts any downstream resume/handoff; + ambiguity is a normal, first-class outcome (`session: null`). +- Bound the cost: resolution runs continuously beside status detection across all panes. + +### Non-goals + +- Resuming, forking, or otherwise mutating an agent session — this layer only reads. +- A cooperative hook/shell-integration provider (agents self-reporting + `{session_id, transcript_path, pid}` over the prowl socket or an OSC sequence) — + designed for later as additional `exact` evidence, deliberately not built now. +- Agents behind SSH, containers, VMs, or nested tmux servers: local pid inspection + cannot see them. + +## Design / Approach + +Resolution is anchored to the detected agent pid and tries evidence strongest-first: + +1. **Open descriptors** (`exact`) — enumerate the pid's open vnode paths via + `proc_pidinfo`/`proc_pidfdinfo` (never `lsof`); a unique recognized session file + (Codex rollout, Amp per-thread log) is decisive. Only descriptors open for + **writing** count: resume pickers open other sessions read-only. +2. **Pid-keyed artifacts** (`exact`) — files naming the pid directly: Copilot's + `logs/process-<epoch-ms>-<pid>.log` ("Registering foreground session: <uuid>") and + Qwen's `<session>.runtime.json` sidecar, validated against the live process + (`schema_version == 1`, pid match, `started_at` sanity) because Qwen intentionally + leaves sidecars behind on quit/crash. +3. **Transcript/screen correlation** (`high`) — bounded tails (128 KiB) of candidate + transcripts compared with the pane's live text; only a unique match with sufficient + score and margin *between distinct sessions* wins (multi-file layouts reinforce + rather than compete). Read budget is per session so a chatty session cannot evict a + competitor from the comparison. +4. **Sole process-lifetime candidate** (`medium`) — storage roots (or OpenCode's sqlite + `session` table) filtered to entries modified during the process lifetime; a single + distinct session id wins, but only after two consecutive agreeing resolutions and + never when another live process already claimed the id (startup race in shared + directories). + +Supporting structure: + +- **One declarative profile per agent** (`AgentSessionProfile`): path grammar, storage + roots and narrowing (Codex day directories, Kimi/Cursor `md5(cwd)`, Gemini + `projects.json` slug + `sha256(cwd)`), cwd encoders (Claude/Qwen + `alphanumericDashed` — every non-alphanumeric → `-`, which is what makes + `~/.prowl/repos/...` worktrees resolvable at all), pid artifacts, store queries. + Prowl targets only the **latest released CLI** of each agent; layout changes edit the + profile in place, no version-detection layers. +- **Caching & retention**: results keyed by `(pid, process start time)` so pid reuse + cannot inherit a session; revalidated every 5 s; unresolved lookups back off + exponentially (1 s → 15 s cap; wide fallback scans start at 8 s). A previously + resolved session survives probe gaps and cache replays but at most two consecutive + *fresh* ambiguous resolutions, so an id rotated away by `/clear` cannot stick forever. +- **Safety caps that preserve the never-wrong invariant**: directory enumeration capped + at 20 000 entries with truncation voiding the whole scan (a partial view could declare + a false unique), known storage roots only (narrow first, wide fallback second, no + home-directory searching), OpenCode's db opened read-only with a 50 ms busy timeout, + failures degrading to "unresolved". + +## Alternatives & decisions + +- **New entry, not an 030 amendment.** This work sits on top of 030's detection (same + pid anchor, same Active Agents membership) and could have been filed as its next + amendment. It was made a standalone entry deliberately: 030 answers *which agent, in + what state* (observation for the panel), while this layer answers *which native + session is it* — a separate resolver with its own consumer contract (`session` in + `prowl agents`), its own per-agent knowledge base, and a forward-looking scope + (handoff/resume/pre-minting) that 030 never had. Both entries cross-link; 030's plan + marks this as its successor wave. +- **Child-process environment variables rejected** (prototyped, then documented so it is + not reattempted). Five CLIs inject their session id into tool child processes + (`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, `COPILOT_AGENT_SESSION_ID`, + `QWEN_CODE_SESSION_ID`, `AMP_CURRENT_THREAD_ID`), but since the macOS 15 hardening + `KERN_PROCARGS2` strips the environment block for non-entitled callers. The variable + names stay valuable for a future cooperative hook provider running *inside* the pane. +- **Never "newest file wins".** The resolver refuses to pick a candidate merely for + recency; every ladder rung demands uniqueness. Consequence accepted: parallel + same-directory sessions with indistinguishable visible text return `session: null`. +- **Latest-CLI-only profiles.** Version drift is real (Kimi migrating `~/.kimi` → + `~/.kimi-code`, Cline 3.x moving to a hub + sqlite, Droid docs disagreeing with the + shipped binary); profiles encode the latest observed truth only, and a future hook + that hands Prowl a `transcript_path` should always beat computed paths. +- **Pre-minting is the plan for Prowl-spawned agents.** When Prowl itself dispatches a + task, choosing the id at spawn (`--session-id`, `create-chat`, `amp threads new`) + removes any need for detection; the research table is the contract for that future + work. Detection exists for user-started sessions. +- **`medium` confidence is explicitly not resume-safe**: documented in + `docs/components/cli.md` — a `medium` id must not drive automatic resume/fork without + additional confirmation. + +## Amendments + +- None yet. diff --git a/docs-ai/045-native-agent-session-detection/001-action.md b/docs-ai/045-native-agent-session-detection/001-action.md new file mode 100644 index 00000000..9bdd90e0 --- /dev/null +++ b/docs-ai/045-native-agent-session-detection/001-action.md @@ -0,0 +1,82 @@ +# 045 — Native Agent Session Detection: Action Log + +All implementation happened on one branch (`feat/agent-session-detection`) over +2026-07-11 and merged as PR #556 on 2026-07-12; the timeline below follows the branch +commits because each row is a distinct review/hardening wave. + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-11 | 12-CLI session-identity research (env vars, hooks, pre-mint, resume, storage encodings), verified against installed CLIs (Codex 0.144.1, Claude Code 2.1.207, Pi 0.79.2, Gemini 0.46.0, Droid 0.147.0, Kimi 1.41.0, OpenCode 1.17.18, Amp 2026-05, Copilot 1.0.70) | PR #556 review thread; [research-cli-session-identity.md](research-cli-session-identity.md) | +| 2026-07-11 | Initial implementation: session resolver + per-agent path parsing, `session` on `PaneAgentState` and in `prowl agents` | `3bb648de` | +| 2026-07-11 | Hardening + declarative `AgentSessionProfile` per agent; Claude cwd encoder fixed to all-non-alphanumeric → `-` (old slash-only rule failed every `~/.prowl/repos` worktree); uniqueness grouped by session id (multi-file layouts); surface re-check after resolver suspension; new exact evidence: Amp thread-log FDs, Copilot pid logs, Qwen runtime sidecars; OpenCode `opencode.db` store query; child-env channel prototyped and rejected | `cff1d78d` | +| 2026-07-11 | Qwen layout verified from source (`qwen-code@deb45ae`) instead of a local install; sidecar validation (`schema_version == 1`, `started_at` vs process start) rejects stale claims on reused pids | `a5fad65a` | +| 2026-07-11 | Adversarial review round 1 (nine findings): sticky-session expiry after two fresh ambiguous resolutions, two-agreeing rule + cross-process claim check for `medium`, writable-descriptors-only, opt-in header enrichment (Gemini only) with full-uuid reads, NFC + per-UTF-16-code-unit cwd encoding, symlink-resolved cwd variants, exponential backoff + 20k enumeration cap, locale-pinned Codex day directories | `47ac3f33` | +| 2026-07-11 | Round 2: truncated enumeration voids the whole scan; fingerprint read budget allocated per session (2 files × ≤12 sessions); `resolve()` reports fresh vs cache-replay so retention ages only on fresh results; Gemini requires a successful header read | `42f335b9` | +| 2026-07-11 | Round 3: truncated primary scan no longer falls through to the (superset) fallback root; unresolved-backoff streak resets when a resolved session turns ambiguous | `6eca2549` | +| 2026-07-11 | Round 4: sessions with no comparable transcript text block fingerprint uniqueness instead of being silently dropped; tails decoded lossily (128 KiB window can cut a multi-byte character) | `4afe3f3a` | +| 2026-07-11 | Round 5: "scoreable" threshold aligned with the 12-character comparison floor so short-fragment sessions cannot count as having testified | `9d3e87af` | +| 2026-07-12 | PR merged; `docs/components/cli.md` documents the `session` field and its confidence semantics in the same change | #556 (`9d8794d0`) | + +## Outcome & current state (as of 2026-07-12) + +Verified against the working tree: + +- **Resolver**: `supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift` — + `AgentSession` (`Source`: `command_line`/`open_file`/`process_log`/`store_record`/ + `transcript_match`/`recent_file`; `Confidence`: `exact`/`high`/`medium`), + `AgentSessionCandidate.uniqueActiveCandidate` (session-id grouping), + `AgentSessionResolution.isFresh`, and the `AgentSessionResolver` actor + (`AgentSessionResolver.shared`). +- **Per-agent knowledge**: `supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift` + — one profile per `DetectedAgent` case (all 12 agents), with `parsePath`, + `candidateRoots`/`fallbackRoots`, `headerSessionIDKeys` + `requiresHeaderSessionID` + (Gemini), `pidKeyedSession`, `storeCandidates`. `AgentSessionPathParser` survives as a + thin compatibility shim over the profiles. +- **Pid artifacts**: `supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift` + (`CopilotProcessLog`, `QwenRuntimeStatus`); **store query**: + `supacode/Infrastructure/AgentDetection/OpenCodeSessionStore.swift` (read-only sqlite). +- **Darwin FD inspection**: `ProcessDetection.openFilePaths(pid:)` in + `supacode/Infrastructure/AgentDetection/ProcessDetection.swift`, filtering to + writable descriptors. +- **State & retention**: `supacode/Domain/AgentDetection/PaneAgentState.swift` carries + `session` + `sessionMissStreak` and the pure `retainedSession` policy; + `supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift` wires + the resolver into detection (`resolveRetainedSession`), including the post-await + surface re-check that prevents ghost Active Agents entries. +- **CLI surface**: `AgentsCommandSession` in + `supacode/CLIService/Shared/AgentsCommandPayload.swift`, populated by + `supacode/CLIService/AgentsCommandHandler.swift`; text mode appends + `session=<id> [<confidence>]` in `ProwlCLI/Output/OutputRenderer.swift`. Behavior + documented in `docs/components/cli.md` (session field, confidence caveat). +- **Tests**: `supacodeTests/AgentSessionResolverTests.swift` and + `supacodeTests/AgentSessionProfileTests.swift` (path parsing, cwd encoders, root + narrowing, FD enumeration against the test process, lifetime filtering, transcript + matching, Copilot/Qwen artifacts, OpenCode fixture db); + `ProwlCLITests/ProwlCLIIntegrationTests.swift` extended for the session payload. + +## Deviations from plan + +None structural — the plan doc was written alongside the implementation and merged in +the same PR. Two support-matrix rows shipped below the verification bar stated for the +rest: Qwen's layout is source-verified only (no local install), and Cursor's open +`store.db` descriptor evidence is path-recognition only, unverified live. Both are +flagged as such in the research doc's support matrix. + +## Open questions + +- Qwen (`runtime.json` sidecar) and Cursor (open `store.db` FD) evidence paths have + never been exercised against a real installed CLI; first field use may surprise. +- Profiles pin "latest released CLI" as of 2026-07-11; known in-flight drift (Kimi + `~/.kimi` → `~/.kimi-code`, Cline 3.x hub + sqlite) will silently degrade those + agents to unresolved until someone edits the profile — there is no drift detection or + telemetry for resolution rates. +- `AgentSession.Source` declares a `command_line` case that nothing emits (the sole + occurrence in the tree is the declaration itself), and `docs/components/cli.md`'s + source enumeration omits it; presumably reserved for pre-minted spawns. Harmless, but + enum and doc disagree on the value set. +- The end-to-end manual acceptance pass (`prowl agents --json` per agent) described in + the research doc's Verification section was defined but, per the PR description, + running the *new* evidence paths end-to-end still needed an updated installed build at + merge time. diff --git a/docs-ai/README.md b/docs-ai/README.md new file mode 100644 index 00000000..4f7ce1b5 --- /dev/null +++ b/docs-ai/README.md @@ -0,0 +1,95 @@ +# docs-ai — Spec-Driven Work Records + +This directory is the durable, chronological record of how Prowl (the onevcat fork) +evolved: one numbered folder per feature or decision-shaping fix/investigation, each +containing the plan that preceded the work and the log of what was actually done. +It exists so that humans and agents can answer "why is it built this way?" without +archaeology through git history. + +## Structure + +``` +docs-ai/NNN-<slug>/ + 000-plan.md # plan / investigation before implementation (RFC-like) + 001-action.md # what was actually done, verified against the code + 002-<topic>.md # amendments: follow-up waves, corrections (indexed in 000-plan.md) + <living>.md # non-numbered = living doc (runbook/ledger/reference), updated in place +``` + +Rules for writing new entries live in the `write-ai-doc` skill +(`.claude/skills/write-ai-doc/SKILL.md`). In short: plan first, act second, amend in +place for in-frame follow-ups, open a new numbered entry for large pivots. Numbered +files are immutable history; non-numbered files are living documents. + +Entries `001`–`045` were backfilled on 2026-07-12 from PRs, commits, and the former +`doc-onevcat/` directory, which was dissolved into this one (historical plans were +absorbed into the entries; operational docs became living files — see the index below; +originals remain in git history). Backfilled plans are retrospective reconstructions; +each is marked as such. + +Living documents hosted here: + +- `001-fork-bootstrap-and-release-pipeline/release-runbook.md` — fork sync & release runbook +- `007-ghostty-embedding-integration/ghostty-fork-sync.md` — Ghostty fork/submodule upgrade runbook +- `012-keybinding-system/architecture.md` — keybinding system reference +- `013-prowl-cli/contracts/` — normative CLI contracts +- `017-upstream-sync-process/upstream-ledger.md` — upstream review ledger (baseline + decisions) +- `020-observability/runbook.md` — observability/diagnostics runbook +- `backfill-open-questions.md` — follow-ups discovered during the 2026-07-12 backfill + +Some entries also host verbatim historical attachments migrated from doc-onevcat (e.g. +`023-shelf-mode/jank-investigation.md`, `017-.../batch-2026-07-06-post-v0.10.5.md`, +`045-.../research-cli-session-identity.md`); they are frozen records, not living docs. + +What is *not* here: small polish PRs (git history covers them) and current user-facing +behavior (`docs/` is the agent-facing manual for that). + +## Index + +| # | Entry | Anchor | Topic | +| --- | --- | --- | --- | +| 001 | [fork-bootstrap-and-release-pipeline](001-fork-bootstrap-and-release-pipeline/000-plan.md) | 2026-02-26 | Independent fork: sync scripts, notarized date-based releases, Sparkle appcast, Homebrew cask | +| 002 | [custom-commands](002-custom-commands/000-plan.md) | 2026-02-27 | Repo-scoped custom command buttons and their evolution | +| 003 | [diff-window](003-diff-window/000-plan.md) | 2026-03-06 | Local diff window (YiTong), external diff tools, render/cache fixes | +| 004 | [prowl-rebrand](004-prowl-rebrand/000-plan.md) | 2026-03-17 | Supacode → Prowl user-facing rebrand; settings migration; upstream-PR guard | +| 005 | [canvas-live-sessions](005-canvas-live-sessions/000-plan.md) | 2026-03-17 | Canvas v1: all tabs as draggable/zoomable cards | +| 006 | [startup-performance](006-startup-performance/000-plan.md) | 2026-03-19 | Parallel repo loading, direct `wt`, snapshot startup cache | +| 007 | [ghostty-embedding-integration](007-ghostty-embedding-integration/000-plan.md) | 2026-03-21 | Ghostty action routing, callbacks, theme handling, safety backports | +| 008 | [terminal-notifications](008-terminal-notifications/000-plan.md) | 2026-03-22 | Command-finished notifications and the notification UX line | +| 009 | [terminal-surface-lifecycle](009-terminal-surface-lifecycle/000-plan.md) | 2026-03-23 | Blank-surface/reattachment investigation; occlusion; leaks | +| 010 | [plain-folder-support](010-plain-folder-support/000-plan.md) | 2026-03-24 | Plain (non-git) folders alongside repositories | +| 011 | [canvas-multiselect-broadcast](011-canvas-multiselect-broadcast/000-plan.md) | 2026-03-25 | Multi-select cards, broadcast input | +| 012 | [keybinding-system](012-keybinding-system/000-plan.md) | 2026-03-27 | Config-driven keybindings M1–M3, recorder UI, Ghostty key ownership | +| 013 | [prowl-cli](013-prowl-cli/000-plan.md) | 2026-03-30 | Contract-first `prowl` CLI: socket service, v1 commands, hardening, agents | +| 014 | [terminal-layout-persistence](014-terminal-layout-persistence/000-plan.md) | 2026-03-31 | Layout snapshot save/restore; font-size persistence; launch races | +| 015 | [repositories-feature-refactor](015-repositories-feature-refactor/000-plan.md) | 2026-04-03 | TCA decomposition of RepositoriesFeature and later code-health splits | +| 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, build speed | +| 017 | [upstream-sync-process](017-upstream-sync-process/000-plan.md) | 2026-04-08 | Upstream review discipline, baselines, batch decisions | +| 018 | [archived-worktrees](018-archived-worktrees/000-plan.md) | 2026-04-09 | Archived worktree discoverability and auto-delete | +| 019 | [worktree-creation-and-lifecycle](019-worktree-creation-and-lifecycle/000-plan.md) | 2026-04-12 | Creation/merge flows, safe deletion, Add-to-Prowl redesign | +| 020 | [observability](020-observability/000-plan.md) | 2026-04-18 | Sentry + PostHog wiring; the App-Hang enable→tune→remove arc | +| 021 | [sparkle-update-ux](021-sparkle-update-ux/000-plan.md) | 2026-04-18 | Update badge, Sparkle 2.9.2, background downloads, confirm-install | +| 022 | [tab-title-and-icon](022-tab-title-and-icon/000-plan.md) | 2026-04-18 | Tab titles/icons: manual, auto-detected, pinned, persisted | +| 023 | [shelf-mode](023-shelf-mode/000-plan.md) | 2026-04-21 | Shelf stacked-book mode + jank investigation | +| 024 | [canvas-interaction-evolution](024-canvas-interaction-evolution/000-plan.md) | 2026-04-25 | Canvas v2 UX: zoom/pan, expand-in-place, spatial navigation | +| 025 | [repo-identity-appearance](025-repo-identity-appearance/000-plan.md) | 2026-04-27 | Per-repo icon/color/title identity | +| 026 | [sidebar-container-refactor](026-sidebar-container-refactor/000-plan.md) | 2026-05-03 | Sidebar container/presentation refactor | +| 027 | [split-pane-ux](027-split-pane-ux/000-plan.md) | 2026-05-04 | Unfocused-pane dimming, divider config, split-zoom | +| 028 | [pr-status-tracking](028-pr-status-tracking/000-plan.md) | 2026-05-08 | GitHub PR state pipeline: batching, correctness, flicker fixes | +| 029 | [active-agents-panel](029-active-agents-panel/000-plan.md) | 2026-05-09 | Active Agents panel UI | +| 030 | [agent-status-detection](030-agent-status-detection/000-plan.md) | 2026-05-09 | Per-pane agent detection: pid API, heuristics, OSC, scheduling | +| 031 | [command-palette-architecture](031-command-palette-architecture/000-plan.md) | 2026-05-16 | Palette rebuild: categories, suggestions, action factories | +| 032 | [performance-hardening](032-performance-hardening/000-plan.md) | 2026-05-21 | App-hang storm fix; event coalescing; render cost reductions | +| 033 | [ui-refresh-2026-05](033-ui-refresh-2026-05/000-plan.md) | 2026-05-24 | Community UI refresh: tab bar, sidebar, chrome tint | +| 034 | [worktree-watcher-correctness](034-worktree-watcher-correctness/000-plan.md) | 2026-05-25 | Watcher/discovery correctness incl. symlinked roots | +| 035 | [protected-terminal-close](035-protected-terminal-close/000-plan.md) | 2026-05-25 | Confirm-before-close for protected terminal work | +| 036 | [window-management-hardening](036-window-management-hardening/000-plan.md) | 2026-05-26 | Main-window surfacing, fullscreen edge cases, stall diagnostics | +| 037 | [line-diff-tracking](037-line-diff-tracking/000-plan.md) | 2026-05-28 | Sidebar line-diff badge pipeline and adaptive debounce | +| 038 | [docs-agent-manual](038-docs-agent-manual/000-plan.md) | 2026-06-07 | docs/ agent manual, sync-docs skill, in-app docs | +| 039 | [gh-cli-hardening](039-gh-cli-hardening/000-plan.md) | 2026-06-08 | gh/git robustness; per-repo GitHub identities | +| 040 | [automatic-open-in](040-automatic-open-in/000-plan.md) | 2026-06-13 | Project-aware Automatic Open In; editor/app additions | +| 041 | [ghosttykit-prebuilt-artifacts](041-ghosttykit-prebuilt-artifacts/000-plan.md) | 2026-06-14 | Prebuilt GhosttyKit downloader (no local Zig needed) | +| 042 | [project-workspaces](042-project-workspaces/000-plan.md) | 2026-06-17 | Workspace grouping of repos/folders | +| 043 | [canvas-tile-layout](043-canvas-tile-layout/000-plan.md) | 2026-06-24 | Tile layout + default-layout setting | +| 044 | [foundation-model-branch-names](044-foundation-model-branch-names/000-plan.md) | 2026-06-27 | On-device FM branch-name suggestions | +| 045 | [native-agent-session-detection](045-native-agent-session-detection/000-plan.md) | 2026-07-12 | Native agent session identity (successor to 030's heuristics) | diff --git a/docs-ai/backfill-open-questions.md b/docs-ai/backfill-open-questions.md new file mode 100644 index 00000000..534ea434 --- /dev/null +++ b/docs-ai/backfill-open-questions.md @@ -0,0 +1,92 @@ +# Backfill Open Questions (2026-07-12) + +Aggregated follow-ups discovered while backfilling entries 001–045 by verifying PRs and +historical docs against the current tree. Each entry's full list lives in its own +`001-action.md` → *Open questions*; this file collects the ones worth acting on or +deciding. Living document: strike items as they get resolved. + +## Likely bugs / risky behavior + +- **`make bump-and-release` conflicts with the notarized-only release policy** + ([001](001-fork-bootstrap-and-release-pipeline/001-action.md)): the upstream-inherited + target creates a GitHub Release with no notarized artifacts, and its `release.published` + event would fire `release-homebrew-cask.yml` against a missing `Prowl.dmg`. `AGENTS.md` + still advertises it. Candidates: delete the target or make it delegate to `scripts/release.sh`. +- **Sparkle channel picker is a no-op** ([021](021-sparkle-update-ux/001-action.md)): + `SparkleUpdateDelegate.allowedChannels(for:)` returns `[]` unconditionally, yet + `UpdatesSettingsView` still shows a Stable/Tip picker wired through `setUpdateChannel`. + Remove the setting or the dead plumbing. +- **Shortening the archived-worktree retention window deletes immediately with no + confirmation** ([018](018-archived-worktrees/001-action.md)) — silent-data-loss UX gap + that issue #174's design notes had asked to guard. +- **Line-diff timing tier never refreshes** ([037](037-line-diff-tracking/001-action.md)): + `repositoryLineChangesTimings` fills only missing roots, so a repo crossing a size tier + keeps its stale debounce tier until relaunch — diverges from the 2026-06-22 plan intent. +- **Prefix-detection mismatch in branch-name suggestions** + ([044](044-foundation-model-branch-names/001-action.md)): `buildPrompt` advertises any + `/`-prefix found in branches but `BranchNameSanitizer.detectConventionPrefix` knows a + fixed list (no `feat/`), so slash-less model output falls back to `worktree/`. + `BranchNameSanitizer` also has no dedicated unit tests. +- **`withExpectedGithubAccount` switch-execute-restore risk** + ([039](039-gh-cli-hardening/001-action.md)): external `gh auth switch` or a crash inside + the window leaves the host pinned to the override account; undocumented in docs/. +- **SIGTERM'd long jobs never notify** ([008](008-terminal-notifications/001-action.md)): + exit codes 130/143 are unconditionally treated as user-initiated. + +## Dead code / drift to clean up + +- `SupacodePaths.originalLegacy*SettingsURL(for:)` unreferenced (repo-root legacy fallback + dropped) — [004](004-prowl-rebrand/001-action.md). +- `SidebarPresentation.showsListHeader(repositoryCount:)` ignores its parameter (leftover + of the removed ">10 repos" rule) — [026](026-sidebar-container-refactor/001-action.md). +- `AgentDetectionSchedule.observedAgent(now:)` ignores `now`; `idleAgentDetectionInterval` + is a misnomer post-#441 — [030](030-agent-status-detection/001-action.md). +- Stale `MaxRects-BSSF` doc comment on `arrangeCards()` in `CanvasView.swift` — + [005](005-canvas-live-sessions/001-action.md). +- `GHOSTTY_PROMPT_TITLE_SURFACE` carries an in-code "consider removing" note; decision + never made — [007](007-ghostty-embedding-integration/001-action.md). +- Close-confirmation copy hardcodes "at least 10 seconds" next to an injectable threshold — + [035](035-protected-terminal-close/001-action.md). +- `RepositoriesFeature+GithubIntegration.swift` re-crossed the 1,000-line ceiling that + PR #403 established — [015](015-repositories-feature-refactor/001-action.md). + +## Docs / ledger corrections needed + +- **Observability runbook drift** ([020](020-observability/runbook.md)): still documents + the App-Hang machinery removed in #236/#241 (a drift warning is stamped at the top); + needs an update pass. +- **Upstream ledger corrections** ([017](017-upstream-sync-process/upstream-ledger.md)): + the 2026-05-08 "Ported" list includes #265/#267 which were closed unmerged; the + 2026-04-20 deferred adoption of upstream #225 (runtime-level background-opacity toggle) + was never executed; snapshot-cache row still says "Pending upstream (#162)" but upstream + #162 is closed unmerged; two commit attributions in the 2026-06-09 table are swapped + (`db2f39d0` belongs to #417, `6fab2d28` is #416's merge). +- **CLI contract drift** ([013](013-prowl-cli/001-action.md)): `contracts/send.md` still + calls `--capture` future; `contracts/read.md` lacks `--wait-stable`; no contracts exist + for `tab`/`pane`/`agents` despite shipped `v1` schema ids; architecture.md's planned + JSON-Schema validation harness (M4) never landed. +- `restoreTerminalLayoutOnLaunch` still default-off "(experimental)" 3.5 months after + shipping; no promote/retire decision — [014](014-terminal-layout-persistence/001-action.md). +- Active Agents panel status-priority sorting (blocked→working→done→idle) was a #274 + follow-up, still unimplemented — [029](029-active-agents-panel/001-action.md). + +## Historical oddities (recorded, no action expected) + +- PR #13 shows MERGED but its merge commit is not an ancestor of main; main was apparently + reset on 2026-03-20 with no note — [006](006-startup-performance/001-action.md). +- #484 (foreground process-group detection fallback) was reverted the next day by direct + commit `5b219791`; issue #495 (OSC 133;C) is the open successor — plain non-OSC-9;4 + commands still show no running spinner — [030](030-agent-status-detection/002-detection-scheduling.md). +- The docs/ manual (commit `49235800`) and a few early fixes landed as direct commits with + no PR — [038](038-docs-agent-manual/001-action.md), [008](008-terminal-notifications/001-action.md). +- The outline used during backfill misattributed the 2026-05 UI refresh to Alex-ai-future; + GitHub shows #326/#331 by abhi21git — docs follow GitHub — [033](033-ui-refresh-2026-05/001-action.md). +- The herdr "keep 3s hold" decision (2026-06-12/13) had no in-repo record before this + backfill; it is now written down in [023](023-shelf-mode/000-plan.md) and + [030](030-agent-status-detection/000-plan.md) from the maintainer's session notes. +- Release-shaped Makefile targets (`archive`, `install-release`) still depend on + `build-ghostty-xcframework` rather than the artifact downloader (`ensure-ghostty`), so a + cold machine source-builds (needs Xcode 26.3) instead of downloading — + [041](041-ghosttykit-prebuilt-artifacts/001-action.md); unclear if deliberate. +- CI runs `make lint` but never `make format-lint`, so the #503 failure class can recur — + [016](016-dev-build-and-ci-workflow/001-action.md).