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