From 130c9bc967dd19e5437b9e0754ebe3657e93644b Mon Sep 17 00:00:00 2001 From: Matthew Klahn Date: Wed, 29 Jul 2026 14:17:16 -0400 Subject: [PATCH] feat: expand stackmap branch workflow --- README.md | 54 +- changelog.md | 47 + ...6-07-28-agent-coordination-requirements.md | 181 ++++ docs/features.md | 38 +- ...1-001-feat-visual-feature-sections-plan.md | 361 +++++++ ...26-07-21-002-feat-agent-status-cli-plan.md | 462 +++++++++ ...1-003-feat-stack-summary-hierarchy-plan.md | 325 ++++++ ...at-agent-coordination-integrations-plan.md | 837 ++++++++++++++++ ...-feat-cross-project-agent-overview-plan.md | 170 ++++ ...1-feat-stackmap-workflow-preflight-plan.md | 799 +++++++++++++++ memory.md | 33 +- src/adapters/github.rs | 16 +- src/app.rs | 940 ++++++++++++++++-- src/app/state.rs | 26 +- src/benchmark_impl.rs | 2 + src/config.rs | 112 +++ src/events.rs | 16 +- src/integration_tests/archive_workflow.rs | 58 +- src/integration_tests/common.rs | 2 + src/integration_tests/github_enrichment.rs | 20 +- src/integration_tests/navigation_checkout.rs | 215 +++- src/integration_tests/refresh_pipeline.rs | 159 ++- src/integration_tests/terminal_interaction.rs | 24 +- src/integration_tests/topology_layout.rs | 191 +++- src/integration_tests/tui_rendering.rs | 498 +++++++++- src/main.rs | 2 + src/model/branch.rs | 21 + src/model/topology.rs | 137 +++ src/model/topology/projection.rs | 46 + src/refresh/builder.rs | 11 +- src/refresh/diffstats.rs | 374 ++++++- src/refresh/mod.rs | 13 +- src/ui/layout.rs | 34 +- src/ui/mod.rs | 6 +- src/ui/panels.rs | 42 +- src/ui/theme.rs | 49 +- src/ui/tree.rs | 405 ++++++-- src/ui/tree/details.rs | 10 +- tests/fixtures/github/pr-list.json | 9 +- 39 files changed, 6436 insertions(+), 309 deletions(-) create mode 100644 docs/brainstorms/2026-07-28-agent-coordination-requirements.md create mode 100644 docs/plans/2026-07-21-001-feat-visual-feature-sections-plan.md create mode 100644 docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md create mode 100644 docs/plans/2026-07-21-003-feat-stack-summary-hierarchy-plan.md create mode 100644 docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md create mode 100644 docs/plans/2026-07-28-002-feat-cross-project-agent-overview-plan.md create mode 100644 docs/plans/2026-07-29-001-feat-stackmap-workflow-preflight-plan.md diff --git a/README.md b/README.md index 96caa9e..f626ec2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `stackmap` is a local-first terminal map for Git branches and Graphite stacks. It keeps the full local branch topology open in a compact, searchable TUI, -shows parent-relative diffstats, marks worktree safety state, refreshes after +shows parent-relative branch diffstats and named-stack base-to-tip totals, marks worktree safety state, refreshes after external Git changes, and optionally adds matching GitHub pull requests. The current release is the unsigned, not-notarized `0.1.0-alpha.1` preview for @@ -87,8 +87,19 @@ Markers are independent of color: Stack colors are deterministic. Yellow is reserved for PR data, while green and red represent insertions and deletions. Set `NO_COLOR=1` for non-color -output. At 120 columns and wider, a detail pane shows the selected branch's -exact local commit time and PR title. +output. At 120 columns and wider, press `d` to show or hide a detail pane for +the selected branch's exact local commit time and PR title. It starts hidden. + +Visual feature sections split a real stack into repository-local presentation +ranges without changing Git or Graphite. Press `i` on a branch to start or +remove a section. Successive sections indent branch-name text while leaving +the real circles, rails, and connectors fixed. Custom stack and section titles +remain white while branch names, dividers, and topology retain identity colors. + +A named stack gets its own title row, followed by one blank hierarchy row. +That title shows the net diff from the validated parent of the stack's bottom +branch to its real displayed tip; it is not a sum of branch rows. Named visual +sections remain directly above the first visible branch they own. ## Keys @@ -102,11 +113,13 @@ exact local commit time and PR title. | `h` | Focus the selected stack and shared ancestry; repeat to show all | | `H` | Focus the selected trunk (or Untrunked); repeat to show all | | `s` | Toggle blank rows between adjacent stacks (on by default) | +| `d` | Show/hide the wide branch-detail sidebar (off by default) | | `+` / `-` / `0` | Increase / decrease lane pitch; reset to automatic width | | `/` | Filter by branch name; ancestors remain as dimmed context | -| `Enter` | Ask Git to switch to the selected branch | -| `c` / `C` | Cycle the selected stack color / open its color picker | -| `n` | Name the selected stack; submit an empty name to clear it | +| `Enter`, `Enter` | Arm and confirm switching the selected branch; one `Enter` edits a selected label | +| `i` | Add/remove a purely visual section boundary on the selected branch | +| `c` / `C` | Cycle/open color for the selected section boundary/label, otherwise its stack | +| `n` | Create a missing stack/section label and edit it inline | | `x` | Archive/restore the selected branch and move focus to the nearest branch above | | `v`, arrows, `Enter` | Preview and apply a contiguous archive/restore range | | `a` | Toggle Active / Archive view | @@ -117,7 +130,8 @@ exact local commit time and PR title. | `Esc` | Close help/message or cancel a filter edit | | `q`, `Ctrl-C` | Quit | -Checkout is intentionally conservative. `stackmap` runs an exact `git switch +Checkout is intentionally conservative. The first `Enter` arms the selected branch and the +second confirms it; `Esc` or navigation cancels. `stackmap` then runs an exact `git switch -- ` after re-reading live repository state. It never stashes, resets, cleans, deletes, or forces. Git's normal overwrite and worktree protections are preserved, and errors leave the current tree untouched. @@ -136,12 +150,14 @@ blocks further deletion when the observed result is inconsistent. Archiving is the normal cleanup operation and never changes Git. Archived names persist in `/stackmap/config.toml`, are hidden from Active view, and can always be restored from Archive view. The current branch and trunks -cannot be archived. Archive mode shows configured-upstream state plus whether -the commit is contained by any locally available remote-tracking ref. Dim, -nonselectable ancestry keeps each archived branch oriented in its stack. It never -fetches: `local only` means no current local remote-tracking ref contains that -commit, not that the commit is absent from the server. `remote ?` and -`unavailable` are intentionally not treated as safe deletion evidence. +cannot be archived. Active and Archive rows show compact configured-upstream +and local remote-ref state in the right-side metadata: `✓ pushed`, `↑n ahead`, +`↓n behind`, `↕n/n div`, `× gone`, `○ no remote`, or `? remote`. Dim, +nonselectable ancestry keeps each archived branch oriented in its stack. This +evidence never fetches: `○ no remote` means there is no configured upstream and +no current local remote-tracking ref contains the commit, not that the commit +is absent from the server. Unknown evidence is intentionally not treated as +safe deletion evidence. ## Refresh and resource behavior @@ -150,7 +166,9 @@ commit, not that the commit is absent from the server. `remote ?` and refresh queue. There is at most one active refresh and one pending request. - Structural snapshots publish immediately while an independent latest-state diff coordinator uses at most four workers and a 2,048-entry object-pair - cache. Older enriched snapshots cannot replace newer structure. + cache shared by branch and stack-title diffs. Older enriched snapshots cannot + replace newer structure. Branch/shared results publish before aggregate-only + stack work, so title summaries cannot delay ordinary branch evidence. - All inter-thread queues, subprocess output, and caches are bounded. - Immutable snapshots are replaced as a unit; stale diff snapshots are rejected by generation, while delayed PR results require a matching branch and object ID. @@ -162,9 +180,9 @@ commit, not that the commit is absent from the server. `remote ?` and - The implementation contains no application `unsafe` blocks. Rust ownership, bounded queues/caches, subprocess timeouts, and snapshot-release tests protect the long-running process from retained generations and unbounded growth. -- Archive-only remote-ref checks use one active/latest-pending coordinator, - bounded targets, output, result queue, deadline, and cache. Leaving Archive - cancels obsolete work; Active view never starts containment checks. +- Visible-row remote-ref checks use one active/latest-pending coordinator with + bounded targets, output, result queue, deadline, and cache in both Active and + Archive views. View and scroll changes replace obsolete work. Run the deterministic 500-branch projection benchmark with: @@ -197,7 +215,7 @@ state; every Git-local branch remains visible as an independent root. The tool never writes or migrates Graphite files. GitHub enrichment is optional. A single-flight, TTL-limited bounded `gh pr list` -request retrieves open PRs, and results attach only when branch name and tip +request retrieves PRs in all states, and results attach only when branch name and tip object ID still match. Missing auth, offline operation, timeout, or malformed JSON is shown as provider state and does not affect local navigation. diff --git a/changelog.md b/changelog.md index f6e1b08..fbfc7e9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,52 @@ # Changelog +## 2026-07-29 + +- Added a reviewed implementation plan for manual Stackmap-aware workflow preflight, dirty-safe committed-parent worktree routing, reversible local skill pilots, and color-coded main-row agent status beside the timestamp. +- Rendered user-authored stack and visual-section titles in white across ordinary, selected, focused, and `NO_COLOR` states while branch names retain their stack/section identity colors and selected-row contrast. +- [LEARN] “Names” in this UI refers to user-authored stack/section titles unless branch names are explicitly mentioned. +- Expanded GitHub enrichment from open-only PRs to all states and added compact `Merged`, `Closed`, and `Approved` status labels, with the full status included in branch detail. +- Added a right-side main-page remote safety column for pushed, ahead, behind, diverged, gone, no-remote, checking, and unavailable states; bounded no-fetch containment evidence now covers visible rows in Active and Archive views. +- Verified formatting, 206 all-target/all-feature tests, benchmarks, strict offline Clippy, and the offline release build; installed the matching `stackmap 0.1.0-alpha.1` binary at `/Users/matt/.cargo/bin/stackmap` with SHA-256 `125d5706162938ad224e1bbe9fe94c134c5fb07026469374758e237850b50e02`. + +## 2026-07-28 + +- Kept focused trunks pinned while making Shift+Down and `J` onto the trunk reposition the scrollable stack rows to their bottom-most viewport state. + +## 2026-07-27 + +- Increased diff-column precision so compact values below ten thousand render one decimal digit, such as `4.3K`, while preserving aligned fixed-width metadata. +- Extended downward stack navigation so Shift+Down and `J` move from the lowest stack to its configured trunk. +- Added standard cursor-aware inline name editing with Left/Right, Home/End, insertion, Backspace, and forward Delete. +- Added total owned branch counts to named stack rows. +- Installed the verified lockfile-exact `stackmap 0.1.0-alpha.1` release at `/Users/matt/.cargo/bin/stackmap`; SHA-256 is `33e420645192bb2186a05da778ffef7331f35a45bdb0f3a141cc2620da5a3390`. +- Added deferred roadmap notes for cross-host agent activity awareness and assisted Stackmap organization, with explicit boundaries around lifecycle reporting, inference confidence, terminal scraping, and Graphite-owned restacking. +- Replaced the easy-to-miss footer-only branch checkout prompt with a centered confirmation popup that names the target and keeps Enter/Escape actions visible at the 40-column minimum. +- Made structural branch-tip updates move the cursor to the newest changed branch visible in the current view, helping surface branches advanced by coding agents without reacting to enrichment-only refreshes. +- Changed selected stack and visual-section labels to retain their identity-color highlight while rendering label and stack-summary diff text in white. +- Bottom-aligned short focused sections above their pinned trunk row, removing the large empty gap previously left by top-aligned content. +- Built the lockfile-exact offline release and installed the matching `stackmap 0.1.0-alpha.1` binary at `/Users/matt/.cargo/bin/stackmap`; SHA-256 is `c659871f5d0a386fd505344cd930dff066201541a9a64ab797b5e5a5492413bc`. + +## 2026-07-21 + +- Added named-stack net diff summaries using each displayed topology group's validated base and real primary tip; branch rows remain parent-relative and side stacks keep independent endpoints. +- Published branch/shared diff enrichment before aggregate-only summary work, retained four-worker/cache/cancellation bounds, and added 500/5,000-group scheduling characterization. +- Added one dedicated nonselectable spacer below stack titles, kept section titles directly adjacent to their owned branches, and added a fixed diff-to-worktree metadata gutter. +- Verified formatting, strict all-target/all-feature Clippy, 194 unit/binary tests, responsiveness benchmarks, 2 doctests, release build, and diff hygiene; installed the lockfile-exact release binary and passed disposable-repository startup/quit smoke testing. +- Added a reviewed implementation plan for true cumulative stack-title diffs, clearer stack/section title hierarchy, and diff-to-worktree spacing, including fork/filter/archive correctness and an eager-enrichment performance gate. +- Added explicit spacing between timestamp and diff columns and between the PR column and the terminal edge, with focused geometry/rendering coverage. +- Added double-Enter branch checkout confirmation: the first Enter arms the exact selected branch, the second executes the existing protected checkout, and Escape or navigation cancels without invoking Git. Updated help/docs and installed the verified build. +- Added a reviewed implementation plan for a versioned read-only agent status CLI, complete per-worktree dirty evidence, and `fm-mobile-review`/`worktree-rules` integration. Deferred JSONL watch, agent annotations, MCP, and mutations until one-shot usage demonstrates need. +- Added persistent, purely visual feature sections: `i` creates/removes branch-anchored boundaries, section ranges accumulate name-only indentation, and Git/Graphite topology remains unchanged. +- Added atomic section name/color persistence with coalesced-write, stale-completion, authoritative-pruning, deletion-cleanup, and cross-refresh protection. +- Added adjacent-safe effective section colors, contextual `c`/`C`, colored dividers/labels/branch names, and non-color depth cues for 40-column layouts. +- Made stack and section labels selectable and editable inline. `n` creates only missing labels; Enter edits selected labels; empty Enter removes a label; Escape cancels and restores selection. +- Fixed name editing so printable navigation letters, key repeats, Backspace/Shift-Backspace, and ordinary text are consumed before global bindings; Ctrl-C cannot quit while editing. +- Added dedicated topology, reducer, input, archive/filter, refresh/coalescing, rendering, `NO_COLOR`, and narrow-width coverage. Parent verification passes formatting, strict offline Clippy, 178 library tests, 8 binary tests, benchmark targets, 2 doctests, and the offline release build. +- Installed the verified local test build at `/Users/matt/.cargo/bin/stackmap` and exercised section creation, inline `j/k/G/J` entry, persistence across restart, unchanged Git OID, and terminal restoration in a disposable repository. +- Made the wide branch-detail sidebar hidden by default and session-toggleable with `d`, returning its width to the branch map when dismissed. +- Improved row contrast: selected rows render branch names and all metadata in black over the full identity-color fill, while the unselected checked-out branch uses a 40% identity-color tint instead of a fixed dark background. + ## 2026-07-20 - Made monitoring non-interfering and event-driven: passive Git commands disable optional locks, relevant filesystem events debounce after a quiet period, noisy `.git` paths are ignored, and periodic reconciliation moved from 30 seconds to five minutes. diff --git a/docs/brainstorms/2026-07-28-agent-coordination-requirements.md b/docs/brainstorms/2026-07-28-agent-coordination-requirements.md new file mode 100644 index 0000000..cd6ecc0 --- /dev/null +++ b/docs/brainstorms/2026-07-28-agent-coordination-requirements.md @@ -0,0 +1,181 @@ +--- +date: 2026-07-28 +topic: agent-coordination +--- + +# Agent Coordination in Stackmap + +## Problem Frame + +Stackmap already makes branch topology, worktrees, committed diffs, pull requests, and cleanup safety legible to a person. It does not show which coding agents are operating in those worktrees, what they have claimed, whether they are active or waiting, or whether a branch changed while an agent was present. A solo developer running several Codex, Claude, or other coding agents must reconstruct that picture from separate application windows and terminal sessions. + +The desired outcome is a trustworthy coordination layer inside Stackmap's normal topology view. It must combine authoritative Git/worktree facts with cooperative agent lifecycle and intent reports without claiming that presence proves authorship, progress, or completion. + +--- + +## Actors + +- A1. Developer/operator: Runs several coding agents and uses Stackmap to understand ownership, activity, collisions, and handoffs. +- A2. Codex desktop agent: Works in a local or linked worktree and reports lifecycle plus explicit intent through an installed Stackmap plugin. +- A3. Claude Code agent: Works in a local or linked worktree and reports through a first-class Stackmap hook/plugin adapter. +- A4. Other coding agent: Uses the documented provider-neutral CLI or MCP contract to publish compatible activity and intent. +- A5. Stackmap TUI: Reconciles Git truth, worktree evidence, and advisory activity into one responsive topology view. +- A6. Agent consumer: Queries Stackmap for repository, worktree, ownership, and attention context before starting or adopting work. + +--- + +## Key Flows + +- F1. Agent arrives in a worktree + - **Trigger:** A supported agent session starts or resumes within a Git repository. + - **Actors:** A2, A3, A4, A5 + - **Steps:** The adapter reports its provider/session and working directory; Stackmap resolves the repository, worktree, branch or detached state, and observed tip; an expiring presence record is created or refreshed; the ordinary branch row gains a responsive activity marker. + - **Outcome:** The operator can see that an agent is present on the correct branch without inferring task success or Git authorship. + - **Covered by:** R3, R4, R5, R9, R12 + +- F2. Agent declares and changes intent + - **Trigger:** An agent begins planning, implementing, testing, reviewing, waiting, blocking, or handing off work. + - **Actors:** A2, A3, A4, A5, A6 + - **Steps:** The agent explicitly reports a bounded intent and phase; Stackmap stores it as agent-reported evidence; the main view updates in place; other agents can query the same state before claiming work. + - **Outcome:** Humans and agents share one current, provenance-labelled coordination picture. + - **Covered by:** R5, R6, R7, R10, R11 + +- F3. Git changes while an agent is present + - **Trigger:** A worktree becomes dirty or a branch tip advances. + - **Actors:** A1, A5, A6 + - **Steps:** Stackmap observes the Git/worktree change independently; the row shows both lifecycle activity and Git evidence; unattributed changes remain unattributed; stale expected OIDs are visible. + - **Outcome:** The operator can distinguish "agent is working" from "repository changed" and see where both coincide. + - **Covered by:** R1, R2, R7, R9, R12 + +- F4. Agent stops, crashes, or hands off + - **Trigger:** A turn stops, a session ends, a heartbeat expires, or an agent explicitly creates a handoff. + - **Actors:** A2, A3, A4, A5, A6 + - **Steps:** A stopped turn becomes idle rather than complete; a clean session end releases presence; a missing session becomes stale and later expires; an explicit handoff may report ready or blocked with before/after OIDs and verification summary. + - **Outcome:** Abandoned claims self-clean, and readiness is never inferred from a lifecycle event alone. + - **Covered by:** R4, R5, R6, R7, R12 + +- F5. Operator installs and verifies an integration + - **Trigger:** The developer installs the Stackmap Codex or Claude integration. + - **Actors:** A1, A2, A3 + - **Steps:** The integration is installed from its supported package surface; hooks are reviewed and trusted where required; a new agent session exposes Stackmap skills/tools; two worktree sessions produce visible, correctly mapped records. + - **Outcome:** Installation through real product surfaces—not a fabricated registry fixture—proves the integration. + - **Covered by:** R8, R10, R13, R14 + +--- + +## Requirements + +**Authoritative repository and worktree status** + +- R1. Stackmap must expose a documented, versioned, deterministic, machine-readable repository status containing exact branch OIDs, validated topology, worktree ownership, committed diff evidence, provider health, and optional presentation metadata. +- R2. Every occupied primary or linked worktree must have independent `clean`, `dirty`, or `unavailable` evidence. Unavailable evidence must never be treated as clean, and uncommitted evidence must remain distinct from committed diff summaries. + +**Provider-neutral activity and ownership** + +- R3. Stackmap must accept a versioned provider-neutral activity contract that can represent Codex, Claude, and other agent runtimes without making any provider's private session model the core domain model. +- R4. Each live activity record must identify its provider, opaque session, repository/worktree, proven branch or detached state, observed tip, lifecycle state, freshness, and provenance. Records must be bounded, atomic, concurrency-safe, and expire through explicit release or TTL. +- R5. Ownership must be an advisory soft claim. Multiple sessions may claim the same branch, worktree, stack, or section, and Stackmap must surface the collision rather than select a winner or block Git. +- R6. Meaningful intent, phase, readiness, and blockers must be explicitly agent-reported. Lifecycle hooks may establish presence, activity, waiting, idle, and freshness but may not infer task success or completion. +- R7. Stackmap must present agent lifecycle evidence and Git-observed dirtiness or tip movement as separate facts. It must not attribute an unclaimed change to an agent or claim that a live agent is actively modifying Git without Git evidence. + +**First-class agent integrations** + +- R8. A locally installable Codex desktop plugin must bundle the Stackmap skills, MCP server, lifecycle hooks, and a launcher that does not depend on the desktop application's inherited shell `PATH`. +- R9. Codex main chats and Codex subagents must appear on the correct worktree/branch, including active, waiting-for-approval, idle, stale, and released transitions. Resumes and compaction must refresh one session rather than duplicate it. +- R10. A first-class Claude Code integration must report main sessions, subagents or agent teams, task lifecycle, worktree changes, and explicit intent through the same provider-neutral model. +- R11. A documented generic CLI/MCP adapter must let another local agent claim, report, hand off, release, and query activity. Unsupported agents are shown only after they use this interface; Stackmap must not pretend to discover arbitrary runtimes automatically. + +**Main-view coordination experience** + +- R12. Agent activity must be visible inside Stackmap's ordinary topology view at the 40-column minimum and progressively disclose provider, count, phase, intent, worktree, freshness, provenance, and OID detail at wider widths without wrapping rows, displacing topology, or requiring a separate dashboard. +- R13. Zero, one, and multiple agents; waiting, idle, stale, blocked, ready, conflicting, and unassigned states must have non-color cues. With no integration or unreadable activity data, existing topology, navigation, filtering, archive, checkout, deletion, and resource behavior must remain usable. + +**Agent workflows and privacy** + +- R14. Supported agent workflows must include orient, coordinate/claim, preflight, query attention, report phase/intent, create handoff, and release. All repository facts must come from the shared Stackmap collector rather than reimplemented Git discovery. +- R15. Read tools must not mutate Git, Graphite, GitHub, worktrees, or remotes. Coordination writes may change only advisory Stackmap metadata; all future repository mutations require a separate approval and revalidation design. +- R16. Stackmap integrations must never automatically capture or persist raw prompts, transcript paths or contents, command strings or arguments, tool responses, assistant/subagent messages, approval descriptions, or file contents. Bounded semantic intent/handoff text is accepted only through an explicit report operation and is labelled agent-reported; adapters must not copy provider payloads into it. Privacy canaries must prove forbidden provider fields do not leak into storage, output, logs, or rendering. + +**Reliability and verification** + +- R17. Passive collection, hook ingestion, MCP operations, storage, subprocesses, queues, caches, and rendering must remain bounded and fail independently. Integration failure must degrade explicitly without hiding Git-local branches or blocking an agent turn. +- R18. Completion requires automated contract, real-Git multi-worktree, concurrency, lifecycle, privacy, responsive rendering, and PTY coverage plus a manual Codex desktop and Claude Code validation using multiple real worktree sessions. + +--- + +## Acceptance Examples + +- AE1. **Covers R2, R9, R12, R18.** Given two linked worktrees on different branches, when one Codex desktop chat runs in each worktree, both branch rows show the correct agent and independent cleanliness evidence without manual refresh. +- AE2. **Covers R5, R12, R13.** Given a Codex chat and a Claude session claiming the same branch, the row shows two agents and a collision cue; selecting it lists both claims and does not prevent either process from using Git. +- AE3. **Covers R6, R7, R12.** Given an agent reporting `testing` with no repository change, Stackmap shows reported testing only; when the worktree later becomes dirty, it separately shows Git-observed activity. +- AE4. **Covers R4, R6, R13.** Given a turn that stops normally, Stackmap shows idle rather than done; given a crashed session, it becomes stale and expires; given an explicit verified handoff, it may show ready. +- AE5. **Covers R8, R9, R14, R18.** Given a newly installed and trusted Codex plugin, a new desktop chat discovers Stackmap skills and MCP tools and automatically creates lifecycle presence; an already-open pre-install chat is not required to do so. +- AE6. **Covers R10, R11, R14.** Given a Claude Code session and a generic test adapter in separate worktrees, both can query existing claims, report their own intent, and appear with distinct provider identities in the same Stackmap view. +- AE7. **Covers R12, R13.** Given the same activity state at 40, 80, and 120+ columns, resizing changes disclosure but not association, counts, topology, selection, or row wrapping. +- AE8. **Covers R16, R18.** Given unique canary values in provider prompts, transcripts, commands, tool outputs, and assistant messages, none appear in registry files, CLI/MCP responses, logs, or the TUI unless a separate explicit semantic-report operation intentionally submits bounded text derived by the agent. + +--- + +## Success Criteria + +- The developer can open Stackmap and immediately see every integrated Codex, Claude, or generic local agent mapped to the branch/worktree it has claimed, including collisions, freshness, and whether Git is actually changing. +- A new agent can query Stackmap before working and correctly discover current worktrees, cleanliness, claims, intents, and attention items without reconstructing topology through separate commands. +- A morning test can install or enable the integrations, start multiple real sessions in linked worktrees, exercise approval waiting, subagents, reported phase changes, Git changes, handoff, collision, clean end, and crash expiry, and observe every transition in the ordinary Stackmap view. +- The implementation is not considered complete until the real Codex desktop and Claude Code surfaces are exercised; fabricated registry fixtures alone are insufficient. +- The existing format, strict lint, complete tests, doctests, release build, bounded-resource checks, no-lock smoke tests, and terminal behavior continue to pass. + +--- + +## Scope Boundaries + +- No hard branch, worktree, stack, or file locks. +- No automatic discovery claim for an agent runtime that has not installed or invoked an adapter. +- No file-level ownership, live cursor location, token accounting, progress percentage, or attribution of specific dirty files. +- No inferred task completion, review verdict, or readiness. +- No prompt, transcript, private application database, terminal, or tool-output scraping. +- No dependency on attaching to Codex's experimental internal App Server. +- No agent-facing Git, Graphite, GitHub, worktree, PR, or remote mutation tools. +- No implicit fetch or server-truth claims from local remote-tracking evidence. +- No cross-machine or cross-user synchronization in the first delivery. +- No separate agent dashboard replacing the topology view. +- Cross-project aggregation is a separate planning deliverable; repository-local coordination remains authoritative for the first implementation. + +--- + +## Key Decisions + +- CLI collector first: A stable repository/worktree status contract is the shared foundation for TUI, MCP, skills, and future consumers. +- Provider-neutral registry: Codex and Claude are first-class adapters over one common model; other agents integrate without new Stackmap subsystems. +- Soft claims with provenance: Coordination improves visibility without becoming a second source of Git authority. +- Main-view integration: Agent information augments the existing topology rather than creating a disconnected dashboard. +- Explicit semantic reports: Hooks provide lifecycle and presence; agents provide bounded intent and readiness. +- Per-session storage: Independent atomic records avoid a high-contention shared heartbeat file and allow bounded degradation. +- Real-surface proof: Desktop/CLI integration evidence is required because registry fixtures cannot prove product installation or hook behavior. + +--- + +## Dependencies / Assumptions + +- Codex desktop plugins continue to support bundled skills, local stdio MCP servers, and trusted lifecycle hooks for new chats. +- Claude Code continues to support plugin or settings-based hooks, MCP servers, skills, worktree events, and subagent lifecycle events. +- Supported integrations can execute a small local Stackmap launcher from their installed package. +- Agent activity remains useful when semantic intent is absent; the UI must say `intent not reported` rather than guessing. +- The current dirty worktree contains valuable uncommitted Stackmap work and must be preserved throughout implementation. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R4, R13][Technical] Choose the production freshness, stale, expiry, and retained-handoff durations while keeping them testable with a short clock override. +- [Affects R8, R10][Needs research] Decide whether the integration package embeds architecture-specific Stackmap binaries, launches a separately installed release, or supports both with explicit fallback. +- [Affects R10][Needs research] Confirm which Claude hook events and plugin package shape provide the most reliable main-session, subagent/team, task, and worktree coverage in the installed version. +- [Affects R12][Technical] Allocate responsive activity geometry without weakening the existing 40-column topology and metadata guarantees. +- [Affects R18][Technical] Determine which desktop and Claude validation steps can be automated and which require final operator interaction. + +--- + +## Next Steps + +-> `/ce-plan` for structured implementation planning. diff --git a/docs/features.md b/docs/features.md index b356032..fc96834 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,6 +20,9 @@ explains which work belongs together or what is safe to clean up. - Current branch, dirty worktree, linked worktree, commit age, parent-relative additions/deletions, and optional open pull request details are visible in one row or the wide detail pane. +- Named stack titles show a direct net diff from the displayed stack's validated + base to its structural tip. Forked stacks use their own attachment parent; + branch rows remain parent-relative. - Structural Git state appears first. Diffstats and GitHub enrichment arrive independently and can never roll the model back to an older generation. - GitHub CLI failures and incompatible Graphite metadata remain visible provider @@ -30,20 +33,33 @@ explains which work belongs together or what is safe to clean up. - Arrow/Vim movement, stack jumps, section-edge jumps, and portable terminal fallbacks work across deep and broad topologies. - Persistent stack names and colors are stored per repository and applied - immediately through bounded, coalesced atomic writes. + immediately through bounded, coalesced atomic writes. A named stack title is + separated from its contents by one dedicated blank row. +- Visual feature sections add named, colored, cumulatively indented ranges + inside a real stack. They move only branch-name text: Git circles, + connectors, refs, and Graphite metadata remain unchanged. Labels are + selectable and edited inline with exclusive keyboard input. +- Fixed metadata gutters separate time from diff, diff from worktree, remote + safety state, and the PR column from the terminal edge whenever those columns + are present. +- The wide branch-detail sidebar starts hidden and toggles with `d`. - `--current` starts with only the current stack and shared ancestry in view. ## Reversible archive workflow - Lowercase `x` archives or restores a branch without modifying Git. Range mode applies the same reversible operation to a contiguous selection. -- Archive view retains required ancestry as dim context and adds no-fetch local - upstream/remote-ref evidence. Unknown evidence is never presented as safe. +- Active and Archive rows show no-fetch local upstream/remote-ref evidence: + pushed, ahead, behind, diverged, gone, no remote, checking, or unavailable. + Archive view retains required ancestry as dim context. Unknown evidence is + never presented as safe. - Branches that become current or configured trunks are automatically restored to Active view. ## Guarded checkout and deletion +- Checkout requires two consecutive Enter presses on the same branch; Escape or navigation + cancels before Git runs. - Checkout delegates to exact `git switch -- ` after live preflight and preserves Git's dirty-tree and linked-worktree protections. - Destructive deletion is isolated on uppercase `X`, requires confirmation, and @@ -62,5 +78,21 @@ explains which work belongs together or what is safe to clean up. broad 10,000-branch combs are covered without recursive emission or viewport scans over the entire repository. +## To-do + +- **Cross-host agent activity:** After the read-only `stackmap agent status` + contract is proven, consider a separate local agent registry that maps + sessions and current working directories to worktrees and branches. It should + support reported states such as working, testing, blocked, waiting, and done; + use heartbeats and TTL expiry; and visibly distinguish reported status from + process/Git inference. Claude Code lifecycle hooks, a cooperative Codex skill, + and optional cmux CLI/socket enrichment can feed the same bounded local + protocol. Do not depend on terminal scraping, private application databases, + or cmux ownership of the agent process. +- **Assisted organization:** Consider a reviewable `stackmap-reorganize` skill + that proposes section boundaries, indentation, and names from topology, + diffs, commits, and agent intent. Stackmap should own validated visual-config + mutations; Graphite should continue to own branch movement and restacking. + See [support.md](support.md) for the platform contract and [graphite-compatibility.md](graphite-compatibility.md) for provider boundaries. diff --git a/docs/plans/2026-07-21-001-feat-visual-feature-sections-plan.md b/docs/plans/2026-07-21-001-feat-visual-feature-sections-plan.md new file mode 100644 index 0000000..5ed0d06 --- /dev/null +++ b/docs/plans/2026-07-21-001-feat-visual-feature-sections-plan.md @@ -0,0 +1,361 @@ +--- +title: "feat: Add visual feature sections" +type: feat +status: completed +date: 2026-07-21 +--- + +# feat: Add visual feature sections + +## Overview + +Add repository-local visual sections inside real Git stacks. A user can press `i` on a branch to start or remove a section, giving that branch and all branches above it a cumulative name-only indent and a distinct color. Sections may be named and recolored, but never alter Git, Graphite metadata, real topology lanes, or connector geometry. The same work upgrades existing stack-name rows into selectable, inline-editable rows and fixes printable navigation letters being lost during name editing. + +--- + +## Problem Frame + +One real branch stack often contains several dependent product features. The current map truthfully shows Git ancestry but cannot visually group those feature ranges. Users need persistent, purely visual boundaries that communicate where one feature ends and the next begins without fabricating Git forks. Existing stack naming also edits in the footer, cannot accept several navigation letters, and produces a label that cannot be selected for later editing. + +--- + +## Requirements Trace + +- R1. `i` toggles a persistent visual boundary anchored to the selected non-trunk branch without changing Git or Graphite state. +- R2. A boundary covers its anchor and every branch above it until the next boundary or real stack end; successive boundaries add cumulative name-only indentation while topology circles/connectors stay fixed. +- R3. Each visual section resolves to a deterministic concrete palette color different from the effective colors of the immediately adjacent ranges, including the unsectioned base range; `c`/`C` recolor a section only from its boundary branch or label and prevent adjacent duplicates in UI-created mutations. +- R4. Removing a boundary atomically removes its name/color and merges its branches into the section below, including that lower section's color treatment. +- R5. `n` on an unnamed section boundary or unnamed stack creates an inline label row at the visual top of its owned range, jumps selection there, and starts editing; an existing name must be selected and opened with Enter. +- R6. Stack and visual-section labels are ordinary selectable rows. Enter edits; an empty confirmed draft removes only the label and returns selection to its anchor/head; Escape restores the prior value. +- R7. Name editing renders the live draft in the tree, reserves the footer for instructions, accepts all printable characters (including `j/k/g/G/J/K`), treats Shift+Backspace as Backspace, and disables all unrelated bindings. +- R8. Boundaries, names, and colors survive restart, concurrent/coalesced config writes, refresh, filtering, focus, and archive projection. A boundary follows its branch if that branch moves to another real stack; absent, trunk, or unmapped anchors are pruned after authoritative refresh. +- R9. The feature preserves 40-column rendering, viewport-bounded work, linear projection metadata, `NO_COLOR` readability, existing real-stack colors, and branch-only action safety. + +--- + +## Scope Boundaries + +- No Git commits, refs, parents, Graphite metadata, restacking, checkout behavior, or remote state is changed. +- Visual sections are flat contiguous ranges within one real stack; their displayed indentation accumulates, but sections do not become a second topology graph. +- Branch-name anchoring follows the existing reversible local-config identity model; stronger rename-stable identity is deferred. +- Manual section colors affect the divider, label, and branch-name text only. Real topology glyphs, rails, connectors, and their stack colors remain authoritative. +- The unsectioned/base range uses the effective real-stack color for its branch-name text and participates as the lower neighbor of the first manual section. +- This work does not change Dependabot or release automation. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `src/config.rs` provides backward-compatible TOML loading, validation, bounded cross-process locking, atomic replacement, and field-aware mutation merging. +- `src/app.rs` overlays pending config changes across refreshes and owns keyboard reducer, editor, color picker, selection, and stale-identity cleanup behavior. +- `src/model/topology.rs` and `src/model/topology/projection.rs` build linear projection metadata before `src/ui/tree.rs` renders only visible rows. +- `StackLabelRow` already emits immediately above a real stack head, but selection maps currently contain branches only. +- `src/events.rs` currently converts `j/k/g/G/J/K` into navigation before the active editor sees them; ordinary character interpretation belongs in normal-mode reduction instead. + +### Institutional Learnings + +- `memory.md` and `changelog.md` require fixed truthful topology, stack-local alignment, reversible repository-local configuration, sequence-safe coalesced writes, 40-column behavior, and all-target verification. +- `docs/solutions/` has no available local artifacts. The existing completed plans establish that projection/index data must remain linear and mutation cleanup must use retained identities rather than current selection. + +### External References + +- None. This feature is fully covered by established Crossterm, Ratatui, reducer, projection, and config patterns in this repository. + +--- + +## Key Technical Decisions + +| Decision | Rationale | +|---|---| +| Persist one visual-section record keyed by anchor branch | Boundary, optional name, and concrete palette color can be created/removed atomically without orphan fields and can follow the branch across topology changes. | +| Use tagged selection targets for branches, stack labels, and section labels | Labels become first-class rows without inventing fake branch IDs or allowing branch-only actions accidentally. | +| Compute section membership/depth from the complete real stack before visibility filters | Search, archive, and focused views cannot make indentation jump or reinterpret membership. | +| Store manual text indentation separately from topology lane/depth | Branch circles and connectors remain truthful and unchanged. | +| Decode ordinary character keys as characters, then interpret navigation in normal mode | Editors receive all printable letters while normal `j/k/g/G/J/K` navigation remains intact. | +| Reuse the existing palette and semantic-color exclusions | Section colors remain consistent with current color accessibility and `NO_COLOR` behavior. | + +Externally edited legacy/conflicting colors are not rewritten merely by viewing the map. Projection resolves them deterministically to a non-conflicting effective color, and the picker displays saved versus effective state using the existing conflict pattern; the next explicit recolor persists a valid concrete choice. + +--- + +## Open Questions + +### Resolved During Planning + +- Boundary direction: the anchor and branches above it form the new section. +- Visual treatment: names and labels shift; topology glyphs do not. +- Naming: `n` creates only missing labels; existing labels are selected and edited with Enter. +- Boundary removal: name/color are removed and the range merges downward. +- Color conflicts: adjacent sections may not share a color; non-adjacent reuse is allowed. +- Creation flow: `i` does not force immediate naming. +- Coincident headers: when a stack label and the highest section label share a head, the stack label renders first, followed by the indented section label, then the branch. + +### Deferred to Implementation + +- Exact internal type/helper names may change to avoid confusion with existing trunk-level `ProjectedSection` types. +- The renderer may choose the narrowest legible divider glyph consistent with 40-column and `NO_COLOR` tests. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + Input["Crossterm input"] --> Reducer["App reducer and editor state"] + Config["Repository-local visual-section records"] --> Reducer + Reducer --> Projection["Full topology projection"] + Projection --> Visible["Filter, scope, and archive visibility"] + Visible --> Render["Fixed topology plus indented colored names"] + Reducer --> Writer["Coalesced atomic config writer"] + Writer --> Config +``` + +The projection assigns each real branch its visual-section anchor and accumulated text-indent level from the complete stack ordering. Visibility then decides which rows emit without recomputing those values. Selection identifies either a branch or a label target. Branch-only actions explicitly reject label targets, while label Enter and contextual `c`/`C` dispatch through the label target. + +--- + +## Implementation Units + +- [x] U1. **Persist atomic visual-section records** + +**Goal:** Extend repository-local configuration and the coalesced mutation path with boundary records that own optional names and explicit palette colors. + +**Requirements:** R1, R3, R4, R8 + +**Dependencies:** None + +**Files:** +- Modify: `src/config.rs` +- Modify: `src/app/state.rs` +- Test: `src/config.rs` + +**Approach:** +- Add a backward-compatible defaulted map keyed by boundary anchor branch, with validation for bounded single-line names and allowed colors. +- Add field-aware visual-section mutations that merge with unrelated archive, stack color, and stack-name writes under the existing lock/atomic-save contract. +- Define the typed section mutation payload needed for App-owned pending overlays, pruning, and deletion reconciliation in U3. + +**Patterns to follow:** +- `ConfigMutation` merge/apply and `App::pending_config_mutation`. +- Existing archive pruning and deleted-identity cleanup. + +**Test scenarios:** +- Happy path: legacy TOML without visual sections loads unchanged; a boundary with color/name round-trips. +- Happy path: one mutation creates a section and another removes its complete record atomically. +- Integration: concurrent config mutations changing archives, stack names, stack colors, and visual sections preserve every unrelated field. +- Error path: invalid colors, multiline names, and overlong names fail without changing the last valid config; an empty editor commit becomes `None` and is never persisted as an invalid empty string. + +**Verification:** +- Visual-section persistence is backward compatible, atomic, sequence-safe, and bounded like existing config features. + +- [x] U2. **Project sections and selectable labels without changing topology** + +**Goal:** Add complete-stack section membership, cumulative manual indentation, boundary/label rows, and tagged selectable targets while preserving real lanes and connectors. + +**Requirements:** R2, R5, R6, R8, R9 + +**Dependencies:** U1 + +**Files:** +- Modify: `src/model/topology.rs` +- Modify: `src/model/topology/projection.rs` +- Test: `src/integration_tests/topology_layout.rs` +- Test: `src/integration_tests/archive_workflow.rs` + +**Approach:** +- Represent branch and label selection with distinct semantic identities; never overload `BranchId` for labels. +- Precompute each valid boundary's segment and cumulative text depth from the authoritative real-stack order before applying filter/archive/scope visibility. Accept an optional ephemeral label/draft projection input so U2 can model its row and selection identity before U3 wires live editor state into it. +- Carry text-indent and section identity on projected branch/label rows separately from topology lane/depth. +- Emit the boundary divider immediately below its anchor. Emit a selectable section label above the first emitted selectable named-visible branch owned by that section for named or actively edited sections; `n` may therefore jump from the anchor to this deliberately floating visible-range header. Context-only rows do not keep a label alive. Do not emit a stored label when its range has no selectable named-visible branch, and repair a disappearing label selection to its anchor/head or nearest visible selectable row without deleting metadata. Existing stack labels remain above the true stack head and become selectable through the same target abstraction. When both headers share the head, emit stack label, section label, then branch. +- Keep projection entries/maps linear and retain branch-only maps where checkout, archive, focus, and details require them. + +**Execution note:** Add characterization coverage for existing stack emission and navigation maps before replacing branch-only selection. + +**Patterns to follow:** +- Iterative stack emission and projection-local indexes in `src/model/topology.rs`. +- Existing stack-label placement immediately above the true head. + +**Test scenarios:** +- Happy path: one boundary indents its anchor and every branch above it exactly once; two boundaries produce cumulative depths. +- Happy path: a named boundary emits its selectable label above the highest branch in its owned range, its divider is immediately below the anchor, and a stack label remains above the real head. +- Happy path: a three-branch fixture spells out exact top-to-bottom entries for one and two boundaries so root-to-head storage cannot invert the screen contract. +- Edge case: side stacks compute independent sections and do not inherit boundaries from a parent stack. +- Edge case: filtering, stack/trunk focus, Active view, and Archive view preserve depths computed from hidden anchors and branches. +- Edge case: unnamed boundaries emit a divider but no empty selectable label; an active empty draft temporarily emits a selectable label. +- Invariant: every real branch retains the same topology lane/connectors with and without visual sections. +- Scale: deep and broad 5,000-branch fixtures keep projection metadata linear. + +**Verification:** +- Section metadata changes only text layout and label selection; topology truth, uniqueness, and scale remain intact. + +- [x] U3. **Implement boundary, color, selection, and inline-editor interactions** + +**Goal:** Make `i`, contextual `n/c/C`, label navigation, Enter editing, and raw-character editor ownership behave exactly as specified. + +**Requirements:** R1, R3, R4, R5, R6, R7, R8, R9 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `src/events.rs` +- Modify: `src/app.rs` +- Modify: `src/app/state.rs` +- Test: `src/integration_tests/navigation_checkout.rs` +- Test: `src/integration_tests/terminal_interaction.rs` +- Test: `src/integration_tests/archive_workflow.rs` +- Test: `src/integration_tests/refresh_pipeline.rs` + +**Approach:** +- Interpret ordinary character navigation only in normal mode so name editing receives raw printable characters first. +- Dispatch the name editor before global quit and repeat filtering. Give it exclusive input ownership over quit/navigation/action shortcuts; printable characters and Backspace repeat as editing input, Enter commits, and Escape rolls back. +- Make Up/Down navigation include label targets. Enter edits labels and only checks out branch targets. +- Dispatch `n` deterministically: a boundary branch with a named section reports that the label must be selected and edited with Enter; a boundary with no name creates/selects its section label; a non-boundary branch in an unnamed stack creates/selects its stack label; and a branch in a named stack reports the same navigate-and-Enter guidance. A boundary never falls through to stack naming. +- `i` creates a boundary with a deterministic allowed color or removes the entire record. Trunks, label rows, and invalid targets produce bounded feedback without mutation. +- Generalize color targeting so `c`/`C` act on a selected section label or exact boundary branch and otherwise preserve stack behavior, including ordinary member branches whose displayed names use a section color. Cycle/picker choices exclude Auto and skip the effective colors of the base/section ranges immediately below and above. Base-stack recoloring also avoids the first section color. +- Revalidate picker adjacency at preview/commit time; if external refresh leaves no valid preview, roll back and report it. Under `NO_COLOR`, unavailable choices remain textually marked. +- Reconcile label selection across save, deletion, refresh, filter, and anchor loss: section labels fall back to their anchor, stack labels to the true head, then both fall back to the nearest visible selectable target. Branch-only actions provide short non-destructive feedback on labels. +- Centralize compatibility around tagged selection: expose explicit selected-branch extraction for branch-only operations and explicit label-owner/visual-row lookup for details, footer status, saved All-view restoration, search restoration, archive range state, deletion recovery, `--current`, stack/section jumps, sticky scrolling, and refresh reconciliation. Labels show owner context without masquerading as the selected branch. +- Track pending section mutations by anchor and sequence; overlay them across config reloads, prune absent/trunk/unmapped anchors through persisted mutations, and include complete section cleanup in successful branch-deletion reconciliation. + +**Patterns to follow:** +- Existing overlay reducers, live color preview/rollback, config registration, selection repair, and mutation refusal messages. + +**Test scenarios:** +- Happy path: `i` creates a distinct-colored boundary and a second `i` removes its boundary/name/color and merges downward. +- Happy path: `n` on an unnamed target creates and selects an inline row; typing then Enter persists it; existing names edit only through label Enter. +- Happy path: `n` on a branch whose relevant section/stack name already exists leaves selection/editor state unchanged and reports that the label must be selected. +- Happy path: empty Enter removes a label and returns selection to its section anchor or stack head; Escape restores the prior text and selection. +- Happy path: `c` and `C` target sections contextually and never choose either adjacent segment's color. +- Edge case: section insertion between two differently colored neighbors chooses a third available color deterministically. +- Edge case: Up/Down visits labels, while stack/section jumps and focus restoration remain deterministic. +- Edge case: label selection preserves or repairs search restore, archive range, deletion recovery, `--current`, focused/sticky scrolling, details, and stack/section jumps. +- Safety: checkout, archive, delete, URL, scope, and other branch-only actions never operate on label targets. +- Input: `j`, `k`, `g`, `G`, `J`, `K`, punctuation, spaces, Backspace, and Shift+Backspace edit text; all normal bindings are disabled until Enter/Escape. +- Input: character and Backspace repeats edit the draft; Ctrl-C is inert while editing rather than quitting. +- Regression: outside the editor, existing character and modified-arrow navigation remains unchanged. +- Integration: pending create/recolor/rename/remove operations survive structural refresh and coalesce without replaying stale state. +- Integration: an older writer completion cannot restore a removed boundary, while only a successful authoritative refresh prunes anchors that are absent, trunks, or mapped to no real stack; stack roots remain valid and failed/partial refreshes never prune. + +**Verification:** +- Every agreed interaction is reducer-owned, deterministic, reversible, and safe on label versus branch targets. + +- [x] U4. **Render inline labels, colored sections, and narrow layouts** + +**Goal:** Render section boundaries, cumulative name indentation, selectable labels, and live editor drafts without moving topology or sacrificing narrow-terminal behavior. + +**Requirements:** R2, R3, R5, R6, R7, R9 + +**Dependencies:** U2, U3 + +**Files:** +- Modify: `src/ui/tree.rs` +- Modify: `src/ui/layout.rs` +- Modify: `src/ui/panels.rs` +- Modify: `src/ui/theme.rs` +- Test: `src/integration_tests/tui_rendering.rs` + +**Approach:** +- Offset only the branch-name/label text origin by the projected manual indent; leave rail and node coordinates driven solely by real lane geometry. +- Render boundary divider, label, and branch-name text with the resolved visual-section color. Preserve semantic diff colors and existing real-stack glyph colors. +- Substitute the editor draft and cursor treatment into the matching label row. Show instructions, not draft content, in the footer. +- Give selected label rows an accessible selection treatment and retain textual structure under `NO_COLOR`. +- Clamp/truncate manual indentation so 40-column rows never wrap and retain useful branch identity. If distinct depths compress to one text column, retain a compact non-color depth/continuation cue so adjacent ranges remain distinguishable. +- Preserve the existing append-and-Backspace editor model, count the established Unicode character limit consistently, keep the end cursor visible under truncation, and report validation failure without losing the draft. + +**Patterns to follow:** +- Cell-based row painting, `RenderGeometry`, overflow cues, semantic diff spans, and edge-to-edge selection styles. + +**Test scenarios:** +- Happy path: branch names and labels shift cumulatively while captured node/connector columns remain byte-for-byte aligned. +- Happy path: section divider, label, and name text use the section color; topology glyphs retain the real stack color. +- Happy path: editor keystrokes update the tree row live and the footer contains instructions without duplicating the draft. +- Edge case: unnamed boundaries, removed labels, selected labels, and adjacent sections render unambiguously. +- Edge case: 40-column output does not wrap and preserves a useful truncated branch name at deep manual indentation. +- Edge case: deep indentation that must compress still distinguishes adjacent section depths without relying on color. +- Accessibility: `NO_COLOR` output retains divider/indent/selection meaning and semantic additions/deletions remain distinguishable. + +**Verification:** +- Rendered output communicates visual grouping while preserving truthful topology and all width/accessibility contracts. + +- [x] U5. **Document, regress, and prepare a local test build** + +**Goal:** Update user-facing controls, prove the complete behavior, and produce an installable release binary for hands-on testing. + +**Requirements:** R1-R9 + +**Dependencies:** U1, U2, U3, U4 + +**Files:** +- Modify: `README.md` +- Modify: `docs/features.md` +- Modify: `memory.md` +- Modify: `changelog.md` +- Test: `src/integration_tests/tui_rendering.rs` +- Test: `src/integration_tests/navigation_checkout.rs` +- Test: `src/integration_tests/terminal_interaction.rs` + +**Approach:** +- Update help/footer documentation for `i`, selectable labels, contextual `n/c/C`, inline editing, and the purely visual/no-Git guarantee. +- Run formatting, strict offline Clippy, all-target/all-feature tests, doctests, and offline release build on pinned Rust 1.88.0. +- Install the verified release binary to `/Users/matt/.cargo/bin/stackmap`, verify its digest matches `target/release/stackmap`, and use a PTY interaction harness in a disposable repository to drive section creation, editor character entry, persistence across restart, and clean quit against the installed artifact. + +**Test scenarios:** +- Integration: create, name, recolor, restart, edit via label Enter, remove the label, remove the boundary, and confirm Git refs/OIDs remain unchanged. +- Integration: exercise multiple adjacent sections plus a real side-stack split and confirm only names receive manual indentation. +- Operational: installed `stackmap` reports the expected version, starts outside the source repository, and restores the terminal after quit. +- Operational: the installed artifact matches the verified release build, doctests pass explicitly, and Ctrl-C during name editing does not exit the PTY session. + +**Verification:** +- Documentation matches the executable, the full established suite passes, and the installed binary is ready for user testing. + +--- + +## System-Wide Impact + +```mermaid +flowchart TB + Events["Input decoding"] --> App["Selection and reducer"] + App --> Projection["Topology projection"] + App --> Config["Config outbox"] + Config --> Reload["Refresh and reconciliation"] + Reload --> App + Projection --> UI["Tree, footer, and help"] +``` + +- **Interaction graph:** Input decoding, selection identity, reducer overlays, projection emission, config persistence/reload, tree rendering, footer/help, and branch mutation guards all change together. +- **Error propagation:** Invalid names/colors or stale anchors produce bounded reducer messages; persistence failures retain the established last-valid config and quit-drain behavior. +- **State lifecycle risks:** Pending writes must not resurrect removed boundaries; ephemeral label rows must disappear on cancel and reconcile safely if their target vanishes. +- **API surface parity:** Runtime facade types should remain private unless existing integration/benchmark seams require exposure; CLI grammar and version stay unchanged. +- **Integration coverage:** Cross-layer tests must prove config reload/persistence, projection membership, navigation/editor state, and rendering together. +- **Unchanged invariants:** Git/Graphite state, real topology lanes/connectors, archive/delete safety, bounded workers, and viewport-linear rendering remain unchanged. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Branch-only selection assumptions are widespread | Introduce one tagged target abstraction and centralize branch extraction/guards rather than patching fake IDs. | +| Hidden boundaries alter visible indentation unexpectedly | Compute depth before filter/archive/scope visibility and test each projection mode. | +| Adjacent colors conflict after external config edits | Resolve effective colors deterministically at projection time and persist only through validated UI mutations. | +| Manual indentation consumes narrow layouts | Keep it independent from lane pitch, saturate offsets, and add explicit 40-column fixtures. | +| Editor loses keys or triggers actions | Preserve raw character events and dispatch the editor before all global bindings. | +| Config cleanup removes unrelated stack metadata | Use distinct stack and section target identities in mutations and deletion reconciliation. | + +--- + +## Documentation / Operational Notes + +- The installed test build should be treated as local evaluation only; no PR, push, release, or tag is part of this plan. +- Document that sections are repository-local visual metadata stored under the Git common directory and do not affect collaborators unless they share that local config. + +--- + +## Sources & References + +- Related code: `src/config.rs`, `src/app.rs`, `src/events.rs`, `src/model/topology.rs`, `src/model/topology/projection.rs`, `src/ui/tree.rs` +- Related plans: `docs/plans/2026-07-19-001-feat-stable-stackmap-workflow-plan.md`, `docs/plans/2026-07-18-001-feat-graphite-stack-lanes-plan.md` +- Project context: `memory.md`, `changelog.md` diff --git a/docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md b/docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md new file mode 100644 index 0000000..ddc22a9 --- /dev/null +++ b/docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md @@ -0,0 +1,462 @@ +--- +title: "feat: Add an agent-facing repository status CLI" +type: feat +status: active +date: 2026-07-21 +--- + +# feat: Add an agent-facing repository status CLI + +## Overview + +Give coding agents a stable, read-only view of the same repository and stack state that Stackmap presents to a person. The first delivery adds a namespaced one-shot JSON status command, trustworthy per-worktree cleanliness, a clearer TUI dirty indicator, and thin integrations for `fm-mobile-review` and `worktree-rules`. A resilient JSONL watch stream is deferred until one-shot usage demonstrates a concrete continuous-observation need. + +The API is a purpose-built, versioned machine contract. It does not serialize TUI state, scrape rendered output, or grant agents new Git mutation authority. + +| Surface | First delivery | Second delivery | Deferred | +|---|---|---|---| +| CLI | `stackmap agent status` | `stackmap agent watch` | Mutation commands | +| Repository state | Git/Graphite topology, OIDs, worktrees, cleanliness, diffs, local visual metadata | Complete replaceable snapshots after changes | Commit/file-detail API | +| Optional providers | Typed config/Graphite health; opt-in bounded GitHub enrichment | Provider transitions without stale claims | Remote fetch or server-truth claims | +| Agent workflow | Review/worktree discovery and preflight | Long-running observation | Agent progress annotations and MCP wrapper | + +--- + +## Problem Frame + +Agents currently rediscover branch relationships, worktree ownership, dirty state, diff summaries, PR state, and Stackmap's visual organization through several commands. That duplicates work and makes it easy to conflate committed parent-relative diffs with uncommitted changes. Stackmap already gathers most of this context, but its TUI and private runtime types are not safe machine contracts, and linked worktree cleanliness is not currently measured. + +The desired result is a fast orientation layer for agent loops. `fm-mobile-review` should spend less time reconstructing the stack before reviewing it, and `worktree-rules` should get a trustworthy preflight snapshot without altering the primary checkout. The separate TUI dirty marker also fulfills the user's explicit request to distinguish uncommitted work from committed diff information. Git, Graphite, and each skill's existing mutation checks remain authoritative at the moment an action is taken. + +--- + +## Requirements Trace + +- R1. Preserve the existing `stackmap [--current] [REPOSITORY]` TUI grammar while adding the unambiguous `stackmap agent status [OPTIONS] [REPOSITORY]` command. +- R2. Emit a documented, versioned, deterministic DTO rather than exposing `App`, `RepositorySnapshot`, or rendered terminal text. +- R3. Report every local branch with exact OID, current/parent/stack/trunk identity, Graphite provenance, parent-relative diff state, worktree ownership, optional PR data, and optional visual stack/section/archive metadata. +- R4. Represent every occupied worktree independently, including primary/linked identity, branch or detached HEAD, exact OID, operation state, and `clean | dirty | unavailable` evidence. Unavailable must never be interpreted as clean. +- R5. Keep one-shot local status bounded and deterministic. Optional GitHub enrichment is opt-in and cannot prevent a usable local snapshot. +- R6. Keep stdout machine-clean, put diagnostics on stderr, and document exit semantics for usable degraded output, invocation/discovery failure, and exhausted consistency retries. +- R8. Update `fm-mobile-review` to use Stackmap status as an orientation accelerator while retaining `gt ls`, actual diff inspection, and immediate OID/Graphite revalidation before mutations. +- R9. Update `worktree-rules` to fail closed on primary cleanliness `dirty` or `unavailable`, detect existing worktree ownership, and revalidate the primary tuple and parent OID before creating a worktree. +- R10. Keep v1 read-only. Local agent progress annotations, MCP packaging, checkout/restack/rename/push operations, and generic shell execution are separate follow-up work. +- R11. Preserve Stackmap's passive-read contract: bounded subprocesses/output/queues, `GIT_OPTIONAL_LOCKS=0`, no fetch, no index refresh, no lock files, and explicit provider degradation. +- R12. Surface uncommitted work separately from committed `+/-` diff information in both the contract and TUI. + +--- + +## Scope Boundaries + +- No Git, Graphite, GitHub, worktree, PR, or remote mutation is added. +- No implicit fetch and no claim that local remote-tracking evidence is server truth. +- No generic agent runtime or embedded language model is added to Stackmap. +- No commit list, changed-file list, cumulative diff, or review verdict is included in the initial status schema. +- Visual section names/colors are presentation metadata, never evidence of actual branch ancestry or review category. +- The machine contract supports valid UTF-8 branch names; filesystem paths need an explicit lossless representation or a marked lossy representation before schema v1 is frozen. + +### Deferred to Follow-Up Work + +- Repository-local agent annotations (`implementing`, `testing`, `reviewing`, `blocked`, `ready`) with agent ID, note, expected OID, timestamp, TTL, atomic writes, and TUI display. +- `stackmap agent watch`, after representative status consumers demonstrate a workflow that cannot be served by repeated bounded one-shot reads and establish the required observation latency. Its follow-up design must resolve interruptible stdout, backpressure, resynchronization, and working-tree observation before implementation. +- A thin MCP adapter over the stable CLI/service contract after real skill usage demonstrates which primitives are valuable. +- Any mutation surface, with a separate plan for dry-run output, exact repository/OID preconditions, approval gates, and postcondition verification. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `src/main.rs` owns a hand-rolled `OsString` parser and preserves option-shaped paths through `--`; the agent namespace must extend this without stealing repository names such as `status` or `watch`. +- `src/lib.rs` deliberately keeps implementation modules private and exports only narrow executable seams. The machine schema needs its own private module and explicit DTO boundary. +- `src/adapters/git.rs`, `src/adapters/command.rs`, and `src/refresh/builder.rs` provide bounded passive reads and before/after consistency checks. +- `src/refresh/diffstats.rs` and `src/adapters/github.rs` already establish bounded enrichment and branch-name-plus-OID attachment patterns. +- `src/config.rs` stores validated visual metadata under the common Git directory using locking and atomic replacement. +- `src/integration_tests/repository_snapshot.rs`, `src/integration_tests/refresh_pipeline.rs`, and `src/integration_tests/tui_rendering.rs` provide real-Git, race, boundedness, and rendering test patterns. + +### Institutional Learnings + +- Git-local inventory remains authoritative; Graphite adds validated topology and must degrade without hiding local branches. +- Structural snapshots and asynchronous enrichments carry generations and OIDs so stale work cannot attach to a newer branch tip. +- The current `dirty: bool` only measures the adapter's active checkout. Linked worktrees are unmeasured, so trustworthy agent preflight requires a tri-state per-worktree model. +- Resource bounds, process-group termination, no index locks, and truthful unknown/unavailable states are public reliability behavior. +- There are no `docs/solutions/` entries for this repository; `memory.md`, `changelog.md`, and the existing plans are the available institutional record. + +### External References + +- No external research is needed for implementation. Local patterns cover Git, Graphite, GitHub, refresh, serialization dependencies, and safety behavior. Agent-native architecture guidance contributes the parity, primitive-tool, dynamic-context, and approval-boundary principles reflected here. + +--- + +## Key Technical Decisions + +| Decision | Rationale | +|---|---| +| Namespace commands under `stackmap agent` | Preserves legacy positional repository paths and leaves room for later agent-specific primitives. | +| Define a schema-versioned report DTO | Prevents private UI/domain types from becoming accidental compatibility promises. | +| Make local status the default; gate GitHub behind `--github` | Keeps startup bounded and deterministic when network/authentication is unavailable. | +| Model cleanliness on worktrees, not branches | Dirty files belong to checked-out working directories; `unavailable` must fail closed. | +| Keep skills authoritative for mutations | Status improves context, but cannot prove a later mutation remains safe after concurrent repository changes. | +| Delay MCP until the CLI contract is exercised | A CLI is universally composable today; observed usage can determine whether MCP needs `status`, `branch_detail`, `watch`, or other primitives. | + +--- + +## Open Questions + +### Resolved During Planning + +- Command naming: use `stackmap agent status`, not a top-level subcommand; reserve `stackmap agent watch` for the separately justified follow-up. +- GitHub behavior: local-only by default; `--github` requests bounded optional enrichment. +- Watch payloads: emit complete replaceable snapshots first; do not expose internal refresh deltas. +- Dirty semantics: `clean`, `dirty`, and `unavailable` per worktree, separate from committed diffstats. +- Failure semantics: provider degradation remains exit 0 when a coherent Git snapshot exists; invocation/discovery failure exits 2; exhausted consistency attempts exit 3. +- Mutation authority: none in this plan. + +### Deferred to Implementation + +- Exact DTO field names and path encoding after representative fixtures validate readability and losslessness. +- Whether worktree dirtiness includes only tri-state evidence in schema v1 or also staged/unstaged/untracked counts; implement counts only if the existing porcelain parser can provide them without weakening bounds. + +--- + +## Output Structure + +Target repo: Stackmap + +```text +src/ + agent/ + mod.rs + report.rs +src/integration_tests/ + agent_status.rs +tests/fixtures/agent/ + schema-v1.json +docs/ + agent-integration.md +``` + +Companion skill workspace changes remain in their existing packages: + +```text +fm-mobile-review/ + SKILL.md +worktree-rules/ + SKILL.md +``` + +This tree is a scope guide; per-unit file lists govern the final layout. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + CLI["stackmap agent status"] --> COLLECTOR["bounded report collector"] + COLLECTOR --> GIT["Git inventory + worktree evidence"] + COLLECTOR --> GRAPHITE["validated Graphite topology"] + COLLECTOR --> CONFIG["local visual metadata"] + COLLECTOR --> DIFF["parent-relative diff enrichment"] + COLLECTOR --> GH["optional GitHub enrichment"] + COLLECTOR --> DTO["schema-versioned complete report"] + DTO --> JSON["one-shot JSON"] + JSON --> REVIEW["fm-mobile-review orientation"] + JSON --> WORKTREE["worktree-rules preflight"] +``` + +The agent receives the user's current repository vocabulary and state through the report. It composes that context with existing Git/Graphite primitives; Stackmap does not encode review or worktree-creation judgment in a workflow-shaped command. + +--- + +## Implementation Units + +```mermaid +flowchart TB + U1["U1 Worktree evidence"] --> U2["U2 Report contract"] + U2 --> U3["U3 Status CLI"] + U3 --> U4["U4 Skill integrations"] + U3 --> U6["U6 Docs and release verification"] + U4 --> U6 +``` + +- [ ] U1. **Model trustworthy per-worktree cleanliness** + +**Goal:** Replace the active-checkout-only dirty assumption with bounded evidence for every occupied worktree and show it independently from committed diffstats. + +**Requirements:** R4, R11, R12 + +**Dependencies:** None + +**Files:** +- Modify: `src/adapters/git.rs` +- Modify: `src/adapters/git/inventory.rs` +- Modify: `src/adapters/command.rs` +- Modify: `src/model/branch.rs` +- Modify: `src/refresh/builder.rs` +- Modify: `src/ui/tree.rs` +- Test: `src/integration_tests/repository_snapshot.rs` +- Test: `src/integration_tests/tui_rendering.rs` + +**Approach:** +- Inventory primary and linked worktrees with stable identity, checked-out branch or detached OID, and primary/linked classification. +- Run the existing bounded passive status command in each distinct accessible worktree. Preserve `unavailable` with a typed reason when a path vanishes, cannot be read, or times out. +- Associate branches with worktree records rather than treating false as both clean and unmeasured. +- Render an explicit dirty marker/badge independently from parent-relative additions/deletions; keep branch topology and colors unchanged. + +**Execution note:** Add characterization coverage for today's primary-checkout dirty behavior before replacing the boolean model. + +**Patterns to follow:** +- Passive command bounds and `GIT_OPTIONAL_LOCKS=0` in `src/adapters/command.rs`. +- Exact worktree ownership refusal in `src/adapters/git.rs`. + +**Test scenarios:** +- Happy path: clean primary plus clean linked worktree reports both clean and renders no dirty marker. +- Happy path: dirty primary and dirty linked worktree each report dirty on the correct worktree/branch while committed `+/-` remains unchanged. +- Edge case: detached and unborn worktrees remain representable without inventing branch ownership. +- Error path: a linked path disappears or becomes unreadable during inspection and reports unavailable, never clean. +- Safety: status commands create no index lock, honor time/output bounds, and terminate descendants on timeout. +- Rendering: selected/current row styling keeps the separate dirty marker legible. + +**Verification:** +- Every occupied worktree has explicit cleanliness evidence and the TUI can distinguish uncommitted changes from committed diffstats. + +- [ ] U2. **Define the versioned agent report contract** + +**Goal:** Create a deterministic public data contract and a bounded synchronous collector without exposing Stackmap internals. + +**Requirements:** R2, R3, R4, R5, R6, R11 + +**Dependencies:** U1 + +**Files:** +- Create: `src/agent/mod.rs` +- Create: `src/agent/report.rs` +- Modify: `src/lib.rs` +- Modify: `src/refresh/builder.rs` +- Modify: `src/refresh/diffstats.rs` +- Modify: `src/adapters/github.rs` +- Modify: `src/config.rs` +- Test: `src/integration_tests/agent_status.rs` +- Create: `tests/fixtures/agent/schema-v1.json` + +**Approach:** +- Define explicit serializable DTOs with `schema_version`, stable repository identity/root, capture time, source fingerprint, process generation, typed repository/provider/freshness states, deterministic branch/worktree ordering, and optional visual metadata. +- Build a one-shot collector from the same Git/Graphite/config/diff/GitHub primitives as the TUI, with an overall deadline and bounded consistency retries. +- Make absence distinguishable from not requested, pending, unavailable, timed out, stale, or confirmed none. A successful one-shot should not leave required local fields in loading state. +- Treat config and Graphite problems as typed degradation while retaining coherent Git inventory. Attach PRs only when branch name and exact tip OID still match. +- Review the candidate schema against a fixture matrix covering clean, dirty, detached, degraded, unavailable, and path-encoding cases before declaring schema v1 stable. + +**Patterns to follow:** +- Before/after source-token validation in `src/refresh/builder.rs`. +- OID-keyed enrichment and stale-result rejection in `src/refresh/diffstats.rs` and `src/adapters/github.rs`. +- Private-module/public-contract boundary in `src/lib.rs`. + +**Test scenarios:** +- Happy path: a multi-stack fixture produces deterministic schema-v1 JSON containing exact OIDs, parents, stacks, worktrees, diffs, and visual metadata. +- Edge case: archived/deleted visual-section anchors are pruned or omitted without corrupting branch truth. +- Edge case: non-UTF-8 worktree paths follow the chosen documented representation and round-trip or carry an explicit lossy marker. +- Degradation: missing/malformed/changing Graphite metadata preserves all local branches and reports typed provider state. +- Degradation: config parse failure and GitHub unavailable/timed out are explicit and do not fail the local report. +- Race: branch OID or Graphite source changes during collection triggers bounded retry; exhausted retries return no incoherent report. +- Contract: the golden fixture is stable, ordered, and contains no private Rust debug representation. + +**Verification:** +- An independent consumer can validate schema version and reliably distinguish trustworthy values from unavailable or stale evidence. + +- [ ] U3. **Add the one-shot agent status CLI** + +**Goal:** Expose the report as a stable, composable command without changing normal TUI invocation. + +**Requirements:** R1, R5, R6, R10 + +**Dependencies:** U2 + +**Files:** +- Modify: `src/main.rs` +- Test: `src/main.rs` +- Test: `src/integration_tests/agent_status.rs` + +**Approach:** +- Extend the parser with `stackmap agent status [--github] [REPOSITORY]`, retaining `--current`, legacy positional paths, help/version behavior, and `--` handling for option-shaped paths. +- Print exactly one JSON document plus newline to stdout. Send human diagnostics only to stderr. +- Return exit 0 for a coherent local snapshot even when Graphite, config, or optional providers degrade, 2 for CLI invocation or repository-discovery failure before output, and 3 when consistency retries cannot produce a coherent snapshot. +- Keep GitHub opt-in with a bounded deadline; do not fetch remotes. + +**Patterns to follow:** +- Existing `CliAction` parser tests and thin process shell in `src/main.rs`. + +**Test scenarios:** +- Happy path: status from repository argument and current directory produces one parseable JSON document and exit 0. +- Compatibility: legacy TUI invocations and a repository path literally named `agent`, `status`, or `watch` remain addressable, including through `--`. +- Option behavior: `--github`, help, version, duplicate/unknown flags, and option-shaped paths parse deterministically. +- Degradation: GitHub/Graphite/config failures remain structured output with exit 0 when Git inventory is coherent. +- Error path: non-repository input emits no JSON, a concise stderr diagnostic, and exit 2; exhausted consistency retries emit no partial document and exit 3. +- Output hygiene: stdout contains no progress, tracing, ANSI control, or provider diagnostic text. + +**Verification:** +- Shell and agent consumers can parse stdout without terminal emulation or log filtering, while all existing TUI CLI tests remain green. + +- [ ] U4. **Integrate status into review and worktree skills** + +**Goal:** Use one coherent snapshot to reduce repeated discovery while preserving each skill's established safety and review authority. + +**Requirements:** R8, R9, R10 + +**Dependencies:** U3 + +**Files:** +- Modify in Codex skills workspace: `fm-mobile-review/SKILL.md` +- Modify in Codex skills workspace: `worktree-rules/SKILL.md` +- Test expectation: none in the Stackmap repository; validate both skills with representative dry-run transcripts and their own package checks. + +**Approach:** +- Teach both skills to discover `stackmap`, require a supported schema version, and fall back to their existing Git/Graphite discovery when unavailable or incompatible. +- For `fm-mobile-review`, consume current branch, parents, stack bounds, OIDs, worktree state, diff summaries, PR/provider health, and optional section labels only for orientation. Continue `gt ls`, actual parent-relative/cumulative diff inspection, tests, and immediate expected-OID validation before amendments, moves, renames, restacks, or submits. +- For `worktree-rules`, capture repository identity plus primary path/branch/OID/cleanliness, fail closed on dirty or unavailable primary evidence, detect existing branch ownership, and revalidate the primary tuple and chosen parent OID immediately before worktree creation and after work. +- At the mutation boundary, bracket a fresh bounded primary-cleanliness read with repository/branch/OID checks so the cleanliness evidence describes the same state; abort as a changed precondition if it is dirty/unavailable or either identity check changes. +- Treat concurrent precondition changes as a distinct stale-snapshot failure with a refresh/retry path. + +**Patterns to follow:** +- Existing authority and fresh-agent constraints in both skill files. + +**Test scenarios:** +- Review: coherent status shortens discovery but real diffs determine Storybook/frontend/backend/tests classification. +- Review fallback: missing binary, unsupported schema, degraded Graphite, or changed OID returns to existing discovery without weakening checks. +- Worktree preflight: dirty or unavailable primary state refuses before filesystem/Git mutation. +- Worktree ownership: an already checked-out branch resolves to its owning path instead of creating a duplicate worktree. +- Race: parent or primary OID changes after snapshot and immediate revalidation aborts with a specific precondition-changed result. +- Invariant: neither skill treats visual names/colors, PR absence, or agent metadata as mutation authorization. + +**Verification:** +- Both skills save discovery work when Stackmap is available and behave at least as safely when it is missing, stale, or degraded. + +- [ ] U6. **Document and release the agent contract** + +**Goal:** Make the CLI usable without reading source and lock its compatibility/safety promises into release verification. + +**Requirements:** R1-R6, R8-R12 + +**Dependencies:** U3, U4 + +**Files:** +- Create: `docs/agent-integration.md` +- Modify: `README.md` +- Modify: `docs/features.md` +- Modify: `docs/support.md` +- Modify: `docs/releasing.md` +- Test: `src/integration_tests/agent_status.rs` + +**Approach:** +- Document command grammar, schema/versioning policy, field trust/freshness semantics, stdout/stderr contract, exit codes, GitHub opt-in behavior, path encoding, and examples for both skills before the first status release. +- Publish a capability map: TUI/status parity for read operations, skill-owned composition, and explicitly unavailable mutation capabilities. +- Add release checks for golden schema output, packaged docs/fixtures, passive no-lock behavior, large-repository bounds, and clean install smoke tests. + +**Patterns to follow:** +- Public behavior and troubleshooting structure in `README.md`, `docs/features.md`, `docs/support.md`, and `docs/releasing.md`. + +**Test scenarios:** +- Documentation examples validate as schema-v1 output and use only released commands. +- Packaged source contains the contract documentation and fixtures. +- Installed binary produces machine-clean one-shot output from a clean fixture and a degraded-provider fixture. +- Passive smoke test shows no index lock, orphan process, unexpected fetch, or material settled CPU use. + +**Verification:** +- An agent author can integrate Stackmap from the public docs alone and knows which facts are authoritative, stale, optional, or advisory. + +--- + +## System-Wide Impact + +```mermaid +flowchart TB + REPO["Git repository + worktrees"] --> CORE["shared bounded collectors"] + CORE --> TUI["human TUI"] + CORE --> API["versioned agent report"] + API --> REVIEW["fm-mobile-review"] + API --> RULES["worktree-rules"] +``` + +- **Interaction graph:** Git/worktree/Graphite/config/GitHub readers feed a shared collector; the TUI retains private state while agent CLI serialization maps into a separate DTO; two external skills consume the DTO and continue using their existing mutation primitives. +- **Error propagation:** Core Git discovery failure prevents output. Optional provider/config/worktree evidence failures remain typed data in an otherwise coherent report. +- **State lifecycle risks:** A report can become stale immediately after emission; exact OIDs, fresh cleanliness evidence, and repository identity exist for mutation-boundary revalidation. +- **API surface parity:** Read-only TUI facts become agent-readable. TUI mutations intentionally have no agent parity in v1 because their stakes require a separately designed approval boundary. +- **Integration coverage:** Real repositories with linked worktrees, concurrent ref/metadata changes, degraded optional providers, non-UTF-8 paths, and broken stdout require cross-layer tests. +- **Unchanged invariants:** Git remains authoritative; Graphite discovery remains read-only and optional; GitHub is optional; no fetch occurs; checkout still requires double Enter in the TUI; deletion remains exact/local/non-force; visual config remains outside Git history. + +--- + +## Agent-Native Architecture Checklist + +- **Parity:** Read-only state visible in the TUI is available to agents; withheld mutation parity is explicit and separately planned. +- **Granularity:** Status exposes repository facts, not workflow-shaped `review_stack` or `create_worktree` decisions. +- **Composability:** Skills can combine the report with Git, Graphite, test, and filesystem primitives for new workflows without changing Stackmap. +- **Emergent capability:** Exact topology, worktree, freshness, provider, and presentation data supports questions and coordination beyond the two initial skills. +- **Dynamic vs static:** The CLI returns live discovered capabilities/provider states; a later MCP wrapper should remain thin. +- **CRUD completeness:** Not applicable to read-only repository truth. Future annotation entities require separate create/read/update/delete/clear semantics. +- **Shared workspace:** Agents observe the same repository/common-dir state as the user and TUI. +- **Accumulated context:** Deferred annotations may add repository-local context; schema-v1 does not create agent memory implicitly. +- **Completion signals:** Owned by invoking agent workflows, not by a status provider. +- **Partial completion/context limits:** Reports are bounded; skills retain their own checkpoints and completion protocol. +- **Context injection:** Every report declares available providers, health, exact identities, and current repository vocabulary. +- **Agent to UI:** Read-only observations need no UI event. Deferred annotations must update through shared storage and watcher notification. +- **Capability discovery:** Schema version, provider states, docs, and the capability map tell agents what is available. +- **Approval matching:** Read-only observation needs no confirmation; all future mutations require a separate expected-state/approval design. +- **Mobile:** Not applicable to this local developer CLI. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Freezing an internal or ambiguous schema | Purpose-built DTO, schema version, golden fixtures, explicit trust/freshness states. | +| Linked worktree reads add latency | Deduplicate paths, bound concurrency/time/output, and record unavailable rather than waiting indefinitely. | +| Optional GitHub makes status nondeterministic | Local-only default and explicit bounded `--github`. | +| A snapshot is used as mutation authorization after repo changes | Include repository/OID identities and require immediate consumer-side revalidation. | +| Cross-repository skill rollout drifts from the CLI schema | Require supported schema versions and retain existing fallback discovery paths. | +| Agent-facing reads accidentally mutate Git state | Reuse passive runners and release-test no locks, no fetch, and process cleanup. | + +--- + +## Phased Delivery + +### Phase 1: Trustworthy one-shot status + +- U1-U4 and U6: per-worktree evidence, schema/collector, `agent status`, skill consumers, public contract documentation, and release checks. +- This is the first useful release and should land independently of watch. + +### Later plans + +- JSONL watch only after one-shot usage establishes a concrete continuous-observation need and acceptable latency. +- Agent annotations, thin MCP packaging, and mutation primitives only after status usage reveals concrete demand. + +--- + +## Documentation / Operational Notes + +- Treat schema-v1 compatibility like a public CLI contract: additive fields are allowed; semantic changes require a new schema version. +- Document that exact OIDs are observation tokens, not locks. Consumers must revalidate before any repository mutation. +- Measure one-shot latency and settled CPU on a large real repository with several linked worktrees. +- Skills should log when they used Stackmap versus fallback discovery so the efficiency gain can be evaluated. + +--- + +## Success Metrics + +- `fm-mobile-review` and `worktree-rules` can establish branch topology, worktree ownership, dirty evidence, and provider health with one initial command. +- No supported workflow treats `unavailable`, stale, visual, or optional-provider data as authoritative mutation evidence. +- One-shot output is deterministic, machine-clean, bounded, and useful when Graphite/GitHub/config degrade. +- Existing TUI invocation and repository safety behavior remain unchanged. + +--- + +## Sources & References + +- Related code: `src/main.rs`, `src/lib.rs`, `src/adapters/command.rs`, `src/adapters/git.rs`, `src/adapters/graphite.rs`, `src/adapters/github.rs`, `src/refresh/builder.rs`, `src/config.rs` +- Related tests: `src/integration_tests/repository_snapshot.rs`, `src/integration_tests/refresh_pipeline.rs`, `src/integration_tests/tui_rendering.rs` +- Related project record: `memory.md`, `changelog.md`, `docs/graphite-compatibility.md`, `docs/plans/2026-07-19-001-feat-stable-stackmap-workflow-plan.md`, `docs/plans/2026-07-19-002-feat-open-source-release-hardening-plan.md` +- Consumer inputs: `fm-mobile-review/SKILL.md`, `worktree-rules/SKILL.md` diff --git a/docs/plans/2026-07-21-003-feat-stack-summary-hierarchy-plan.md b/docs/plans/2026-07-21-003-feat-stack-summary-hierarchy-plan.md new file mode 100644 index 0000000..0717237 --- /dev/null +++ b/docs/plans/2026-07-21-003-feat-stack-summary-hierarchy-plan.md @@ -0,0 +1,325 @@ +--- +title: "feat: Clarify stack summaries and section hierarchy" +type: feat +status: completed +date: 2026-07-21 +--- + +# feat: Clarify stack summaries and section hierarchy + +## Overview + +Make named stacks easier to scan by showing the net diff for the complete displayed stack on its title row, separating that title from its contents, placing every named visual section directly above the branches it owns, and adding a blank gutter between deletion and worktree metadata. + +Branch rows keep their existing parent-relative diffs. The title summary is a separate base-to-tip comparison for the complete structural stack, so filtering, focus, archive visibility, or intermediate reversions cannot distort it. + +--- + +## Problem Frame + +The current stack title and first section title can appear as consecutive peer-looking rows. That makes the overall stack name hard to distinguish from its feature sections. Dense metadata has a similar issue: the deletion value and worktree marker can touch visually. + +The stack title also provides no quick measure of total change. Reading or mentally combining every branch diff is slow and can be incorrect because additions and deletions may be modified or reverted later in the stack. The title needs a true net comparison from the displayed stack's validated base to its structural tip. + +--- + +## Requirements Trace + +- R1. A named stack title shows a cumulative `DiffState` for the complete real displayed stack while each branch retains its parent-relative diff. +- R2. Cumulative diff compares the validated parent OID of the displayed stack group's bottom branch directly to that group's primary-chain head OID. It never sums branch stats or uses the broader `Branch.stack_root` identity. +- R3. Forked/side stacks receive independent summaries from their own attachment parent to their own tip; sibling and parent-stack changes are excluded. +- R4. Summary endpoints and values are invariant under filter, focus, archive mode, context rows, ordering, and scroll position. +- R5. Stack summaries reuse the existing bounded OID-pair cache, task deduplication, four-worker cap, generation cancellation, and Loading/Ready/Unavailable lifecycle. +- R6. A named stack projects as `StackLabel → exactly one dedicated blank spacer → first visible section title or branch`. The dedicated title spacer is independent from the user's general stack-separator toggle. +- R7. A named visual section title is emitted exactly once and sits immediately above the first visible selectable branch it owns, with cumulative manual indentation derived from the complete stack. Hidden anchors do not suppress a title when an owned descendant remains visible; context-only ancestry alone does not create a title. +- R8. Unnamed stacks do not gain a synthetic summary row or title spacer. Creating an inline stack name preview creates both; canceling or confirming empty removes both when no saved name exists. +- R9. Stack-label summary metadata uses the normal diff styling and selected-row contrast, but title rows show no timestamp, worktree, or PR values. +- R10. When the worktree range is present, a one-cell blank gutter separates it from the full diff range at every supported width, while preserving the existing time-to-diff and PR-edge gutters when their corresponding columns exist. +- R11. Added rows/gutters do not move topology circles, become selectable/sticky, wrap 40-column rows, or break the minimum branch-name width. + +--- + +## Scope Boundaries + +- No Git refs, commits, Graphite metadata, worktrees, or remotes are changed. +- No cumulative diff is shown for unnamed stacks; naming remains the explicit way to create a summary/header surface. +- No section-level cumulative diff is added in this iteration. +- No branch diff semantics change: branch rows remain validated parent-to-branch comparisons. +- No timestamp, worktree, or PR metadata is added to stack titles. +- No color-palette expansion is included in this plan. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `src/model/topology.rs` owns real stack-group formation and bottom-up projection. Its group identity and complete branch list are authoritative; visible stack heads alone are not. +- `src/model/topology/projection.rs` defines distinct selectable stack/section labels and nonselectable divider rows. +- `src/refresh/diffstats.rs` already schedules arbitrary OID-pair diffstats through a bounded cache and worker pool with generation cancellation. +- `src/adapters/git.rs` computes a net `git diff --numstat` for arbitrary base/head OIDs and already handles binary-file counts. +- `src/ui/tree.rs` has shared diff painting plus separate stack-label, section-label, divider, and branch renderers. +- `src/ui/layout.rs` already reserves time-to-diff and PR-edge gutters; worktree currently begins immediately after the diff range. + +### Institutional Learnings + +- Visual section ownership/manual depth is computed from the complete real stack before visibility filtering; preserve that invariant when repositioning labels. +- Stack and section labels are semantic selectable targets, not fake branches. Branch-only Git/config actions must remain unable to operate through them. +- Selected metadata becomes black over the identity-color background; normal additions/deletions remain green/red. +- Projection/rendering must stay iterative, indexed, viewport-bounded, and safe at the 40-column minimum. +- No `docs/solutions/` entries exist; `memory.md`, `changelog.md`, prior plans, and characterization tests provide the local record. + +### External References + +- None. The repository already contains direct patterns for topology identity, arbitrary endpoint diffstats, asynchronous enrichment, label projection, metadata geometry, and narrow rendering. + +--- + +## Key Technical Decisions + +| Decision | Rationale | +|---|---| +| Compare stack base directly to stack tip | Produces the true net stack change even when intermediate branches rewrite or revert files. | +| Key summaries by displayed topology-group ID | Keeps side-stack summaries independent; `Branch.stack_root` can span multiple displayed groups. | +| Extend the existing diff enrichment pipeline | Shares cache entries, worker bounds, cancellation, and failure handling instead of adding another coordinator. | +| Store summary state in the generation-scoped snapshot | Structural and enriched snapshots remain immutable and stale results cannot attach to newer topology. | +| Show summaries only on named stack labels | Avoids inventing new rows/navigation targets for every unnamed stack. | +| Prefer eager all-group enrichment only behind a characterization gate | It keeps inline naming previews immediately useful without coupling refresh workers to mutable config, but must not consume unacceptable repository-wide work. If it misses the existing responsiveness budget, switch to saved-name targets plus on-demand preview enrichment. | +| Use a dedicated projection spacer after stack titles | Establishes hierarchy without coupling it to optional inter-stack spacing or renderer-only row insertion. | +| Keep section labels adjacent to owned branches | Makes the label's scope legible and preserves existing editing/selection semantics. | + +--- + +## Open Questions + +### Resolved During Planning + +- Cumulative meaning: direct validated base-to-tip diff, not sum of branch rows. +- Fork behavior: every displayed topology group has independent endpoints. +- Visibility behavior: aggregate scope is structural and does not shrink with the viewport/filter/archive. +- Unnamed stacks: no summary/header row until a name is created. +- Missing base/head: show Unavailable rather than zero or guessed Git ancestry. +- Spacer behavior: exactly one stack-title spacer, independent from `s`; no extra spacer after section titles. + +### Deferred to Implementation + +- The internal snapshot field/helper names and whether stack endpoint discovery is materialized in the topology index or returned through a narrow query. +- Exact helper/type names after implementation reveals the cleanest private boundary. +- Eager versus targeted scheduling after measuring branch-only and branch-plus-stack enrichment on representative 500- and 5,000-stack fixtures. Eager scheduling is acceptable only if structural publication remains immediate, cancellation remains bounded, and the existing responsiveness suite passes without a material regression; otherwise saved names are scheduled eagerly and a newly created preview begins Loading while an on-demand endpoint task runs. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + TOPOLOGY["complete topology groups"] --> ENDPOINTS["stack ID + validated base/head OIDs"] + ENDPOINTS --> ENRICH["shared bounded OID-pair diff enrichment"] + ENRICH --> SNAPSHOT["generation-scoped stack diff states"] + SNAPSHOT --> PROJECTION["named stack and section rows"] + PROJECTION --> TITLE["stack title + cumulative diff"] + PROJECTION --> SPACER["dedicated blank hierarchy row"] + PROJECTION --> SECTION["indented section title above owned branch"] +``` + +For a linear stack `A → B → C`, the title compares `parent(A)` to `C`; branch rows continue to compare `parent(A) → A`, `A → B`, and `B → C`. For a child stack `D → E` attached to `B`, its title compares `B → E` and excludes changes unique to the parent stack's later branches. + +--- + +## Implementation Units + +- [x] U1. **Add whole-stack diff state and enrichment** + +**Goal:** Produce correct, bounded cumulative diff state for every real displayed stack group. + +**Requirements:** R1-R5 + +**Dependencies:** None + +**Files:** +- Modify: `src/model/branch.rs` +- Modify: `src/model/topology.rs` +- Modify: `src/refresh/builder.rs` +- Modify: `src/refresh/diffstats.rs` +- Modify: `src/integration_tests/common.rs` +- Test: `src/refresh/diffstats.rs` +- Test: `src/integration_tests/refresh_pipeline.rs` +- Test: `src/integration_tests/repository_snapshot.rs` + +**Approach:** +- Expose a narrow topology-group endpoint query keyed by the real displayed stack ID: bottom branch, validated diff parent, and primary-chain head. +- Initialize every emitted stack summary as Loading in structural snapshots. During enrichment, resolve missing validated parents/endpoints to Unavailable and valid endpoints to Ready or a typed Git failure. +- Extend diff tasks so one OID pair may update branch indexes, stack IDs, or both. Schedule branch targets before aggregate-only targets, then preserve pair deduplication, the shared cache, four-worker cap, bounded channel, spawn-failure completion, and obsolete-generation cancellation. +- Publish branch and stack diff states together in the same immutable enriched snapshot. + +**Execution note:** Start with endpoint/fork correctness tests and the eager-scheduling characterization gate. An incorrect identity choice can produce plausible totals, while an unmeasured eager policy can consume refresh capacity for invisible rows. + +**Patterns to follow:** +- `src/refresh/diffstats.rs` OID-pair cache/task model. +- `src/model/topology.rs` real group formation and primary-chain ordering. + +**Test scenarios:** +- Happy path: linear `A → B → C` summary equals a direct `parent(A) → C` diff while branch stats remain parent-relative. +- Correctness: an intermediate addition later reverted produces a zero/net endpoint result rather than the sum of branch stats. +- Fork: a child `D → E` attached to `B` reports `B → E`, excluding parent/sibling changes. +- Edge case: Untrunked/degraded stack without a validated base reports Unavailable, not zero. +- Efficiency: identical branch/stack endpoint pairs share one task and cache entry. +- Scale: total scheduled work is bounded by unique branch pairs plus real stack groups, and branch diff tasks are not queued behind aggregate-only work. +- Performance decision: 500- and 5,000-stack characterization either validates eager scheduling against the existing responsiveness suite or exercises the targeted/on-demand fallback, including a preview that transitions Loading → Ready. +- Lifecycle: structural snapshot shows Loading; same-generation enrichment publishes Ready/Unavailable; an obsolete generation publishes nothing. +- Failure: worker spawn or Git diff failure completes every pending branch and stack target as Unavailable. + +**Verification:** +- Every named stack can resolve a generation-correct summary from its true displayed-group endpoints without adding unbounded work. + +- [x] U2. **Separate stack titles from section-owned branches** + +**Goal:** Make the visual hierarchy unambiguous while preserving semantic selection and full-stack ownership. + +**Requirements:** R4, R6-R8, R11 + +**Dependencies:** U1 + +**Files:** +- Modify: `src/model/topology.rs` +- Modify: `src/model/topology/projection.rs` +- Modify: `src/app.rs` +- Test: `src/integration_tests/topology_layout.rs` +- Test: `src/integration_tests/navigation_checkout.rs` +- Test: `src/integration_tests/archive_workflow.rs` + +**Approach:** +- Keep `StackLabelRow` identity-only and do not convert it into a branch row. The renderer resolves the latest summary from the active snapshot by `stack_id`, matching how branch rows receive enriched state without rebuilding projection/navigation. +- Emit exactly one nonselectable dedicated spacer after every emitted stack label, including inline editor previews, before any section title or branch. +- Keep named section labels immediately above the first visible selectable branch owned by that section. Determine ownership/manual depth from the complete stack; context-only ancestry does not create an orphan label. +- Preserve navigation order and selection repair: Up/Down moves from stack label over the spacer to a section label or branch; the spacer is never selected or sticky. +- Preserve empty/cancel editor behavior so a transient unnamed-stack label and its spacer disappear together. + +**Patterns to follow:** +- Existing `SelectionTarget` separation and visibility-derived navigation maps in `src/model/topology/projection.rs`. +- Existing floating section-label behavior under filtering/archive. + +**Test scenarios:** +- Ordering: named stack plus named first section emits `StackLabel → Spacer → VisualSectionLabel → Branch`. +- Ordering: named stack plus unnamed first section emits `StackLabel → Spacer → Branch`; a later named section remains directly above its first branch. +- Unnamed stack: no synthetic stack label, summary, or title spacer. +- Filter: hidden section anchor plus visible owned descendant floats the title directly above that descendant; no visible owned branch means no title. +- Archive: context-only ancestry does not create a section title and remains nonselectable. +- Navigation: label → section/branch skips the spacer; sticky bottom/trunk row behavior remains unchanged. +- Editing: `n` on an unnamed stack creates label + spacer preview; Escape or empty confirmation removes both; editing an existing name preserves ordering. +- Fork: parent and child stack labels retain separate identities/summaries. + +**Verification:** +- The projection alone encodes the intended hierarchy and all navigation/sticky maps remain correct after the extra row. + +- [x] U3. **Render stack summaries and metadata gutters** + +**Goal:** Present cumulative totals clearly and add breathing room between deletion and worktree columns without breaking narrow layouts. + +**Requirements:** R1, R9-R11 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `src/ui/layout.rs` +- Modify: `src/ui/tree.rs` +- Test: `src/integration_tests/tui_rendering.rs` + +**Approach:** +- Resolve a stack label's latest cumulative state from the active snapshot by `stack_id`, then paint Loading/Ready/Unavailable through the shared diff-column renderer. Reuse the existing right-aligned muted `+? -?` unavailable representation at every width. Leave title timestamp, worktree, and PR ranges blank. +- Apply label emphasis and selected-row black foreground to every summary glyph/cell exactly as for branch metadata. +- Reserve one additional cell in metadata width and advance the worktree range so it begins one column after the complete diff range. Preserve the existing time-to-diff and PR-edge cells. +- Keep graph coordinates fixed, names truncated before metadata, the minimum name width intact, and blank gutters blank even on selected rows. + +**Patterns to follow:** +- Shared `paint_diff` semantics and selected-row foreground normalization in `src/ui/tree.rs`. +- Existing geometry breakpoint and gutter tests in `src/integration_tests/tui_rendering.rs`. + +**Test scenarios:** +- Ready title shows cumulative green additions/red deletions in the fixed diff column; branch rows retain their own values. +- Loading and Unavailable titles use existing loading/muted semantics without fake zeros. +- Selected stack title renders name and summary black over its selected background; dim emphasis remains consistent. +- Geometry at widths 40, 55/56, 71/72, medium, and wide keeps a blank time→diff, diff→worktree, and PR→edge cell whenever those columns exist. +- Worktree absent/present leaves the new gutter blank and starts `⎇`/basename after it. +- Long/indented names truncate without overwriting rails, summary metadata, or wrapping. +- Visual hierarchy renders a visibly blank row below the stack title and no blank row between section title and owned branch. + +**Verification:** +- The screenshot's dense metadata and ambiguous title hierarchy are visibly resolved at representative widths without changing topology geometry. + +- [x] U4. **Document and verify the completed interaction** + +**Goal:** Record the new semantics and validate them across real and degraded repositories before installation. + +**Requirements:** R1-R11 + +**Dependencies:** U1-U3 + +**Files:** +- Modify: `README.md` +- Modify: `docs/features.md` +- Modify: `memory.md` +- Modify: `changelog.md` +- Test: `src/integration_tests/refresh_pipeline.rs` +- Test: `src/integration_tests/topology_layout.rs` +- Test: `src/integration_tests/archive_workflow.rs` +- Test: `src/integration_tests/tui_rendering.rs` + +**Approach:** +- Document cumulative base-to-tip versus branch parent-relative diff semantics and the named-stack-only summary surface. +- Document stack-title/section-title hierarchy and the three metadata gutters. +- Verify the complete formatting, strict lint, all-target/all-feature test, benchmark, doctest, release-build, diff-hygiene, install-hash, and disposable-repository smoke contracts already used by the project. + +**Patterns to follow:** +- Existing feature descriptions and verification record in `README.md`, `docs/features.md`, `memory.md`, and `changelog.md`. + +**Test scenarios:** +- Integration: a real linear Git/Graphite fixture displays direct net stack totals and parent-relative branch totals simultaneously. +- Integration: a real fork produces independent parent/child stack summaries. +- Degradation: missing Graphite parentage leaves the stack title summary unavailable while the repository remains navigable. +- Regression: filter/archive/focus/name editing, 40-column rendering, selected/current styling, checkout confirmation, and terminal restoration remain intact. + +**Verification:** +- The behavior is documented, fully verified, and the installed binary exactly matches the tested release artifact. + +--- + +## System-Wide Impact + +- **Interaction graph:** Structural Git/Graphite inventory builds topology groups; shared diff enrichment resolves branch and stack targets; projection attaches stack state to title rows; renderer paints the same semantic diff columns for titles and branches. +- **Error propagation:** Missing/invalid endpoints and Git diff failures become stack-level Unavailable without hiding branches or failing structural refresh. +- **State lifecycle risks:** Stack summaries must be generation/OID scoped; visibility-only reprojection must not invent endpoints or retain results from obsolete structural snapshots. +- **API surface parity:** The private snapshot gains aggregate state, but CLI/config/public runtime behavior remains unchanged. +- **Integration coverage:** Real endpoint diffs, forks, filters, archive context, editor previews, sticky rows, selected styles, and width breakpoints require cross-layer tests. +- **Unchanged invariants:** Git and Graphite remain read-only during refresh; all local branches remain visible; work stays bounded; topology circles/rails do not move; branch diff semantics and Git mutation safety do not change. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Summed or wrong-root totals look plausible | Derive direct endpoints from real topology groups and test reversions plus side-stack forks. | +| Invisible aggregate work delays useful branch diffs | Prioritize branch tasks, characterize eager scheduling at 500/5,000 stacks, and fall back to saved-name targets plus on-demand preview enrichment if responsiveness regresses materially. | +| Added snapshot field causes fixture churn | Centralize default stack-summary construction in shared fixtures/builders. | +| Spacer shifts visual indexes/navigation | Build maps from emitted entries and test navigation, focus bounds, sticky rows, and selection repair. | +| Filter/archive changes aggregate meaning | Resolve endpoints from complete topology before projection visibility decisions. | +| New gutter steals narrow name space | Include it in geometry accounting and test every metadata breakpoint plus the 40-column floor. | + +--- + +## Documentation / Operational Notes + +- Describe the stack title total as “net diff from stack base to tip,” never “sum of branches.” +- Loading/unavailable title summaries are normal enrichment states and must not block navigation. +- A stack must be named before its cumulative total gets a persistent row; this keeps unnamed-stack density unchanged. + +--- + +## Sources & References + +- Related code: `src/model/topology.rs`, `src/model/topology/projection.rs`, `src/refresh/diffstats.rs`, `src/adapters/git.rs`, `src/ui/layout.rs`, `src/ui/tree.rs` +- Related tests: `src/integration_tests/refresh_pipeline.rs`, `src/integration_tests/repository_snapshot.rs`, `src/integration_tests/topology_layout.rs`, `src/integration_tests/navigation_checkout.rs`, `src/integration_tests/archive_workflow.rs`, `src/integration_tests/tui_rendering.rs` +- Related project record: `memory.md`, `changelog.md`, `docs/plans/2026-07-21-001-feat-visual-feature-sections-plan.md` diff --git a/docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md b/docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md new file mode 100644 index 0000000..c16b131 --- /dev/null +++ b/docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md @@ -0,0 +1,837 @@ +--- +title: "feat: Show Codex, Claude, and other agents in Stackmap" +type: feat +status: planned +date: 2026-07-28 +deepened: 2026-07-28 +origin: docs/brainstorms/2026-07-28-agent-coordination-requirements.md +supersedes: docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md +--- + +# feat: Show Codex, Claude, and other agents in Stackmap + +## Overview + +Add a trustworthy local coordination layer to Stackmap so its ordinary branch topology shows which Codex, Claude, and generic coding agents are present, what they have cooperatively claimed, what phase and intent they explicitly reported, and whether Git independently shows change in the same worktree. + +The complete delivery has four required surfaces, but execution uses explicit completion tiers so visible local value is not held hostage by release packaging: + +| Surface | First delivery | +|---|---| +| Repository truth | Versioned one-shot status with exact OIDs, topology, worktrees, and independent `clean | dirty | unavailable` evidence | +| Coordination truth | Provider-neutral, expiring soft claims and lifecycle records with explicit provenance | +| Agent integrations | Installable Codex desktop and Claude Code plugins plus a generic CLI/MCP adapter | +| Human view | Responsive activity cues and detail inside the existing topology view | + +Codex and Claude are hard acceptance targets. “Other agents” are visible after they use the documented generic contract; Stackmap does not claim to discover arbitrary runtimes automatically. + +Completion tiers: + +1. **Foundation:** trustworthy worktree/status and provider-neutral presence. +2. **First visible slice:** a generic participant appears and transitions in the real main view. +3. **Morning-test critical:** local development installs make real Codex and Claude sessions, subagents, intent, collisions, and Git evidence visible. +4. **Release critical:** public packaging, marketplace manifests, multi-architecture artifacts, CI, documentation, and real operator acceptance. + +The plan is not complete until all four tiers land, but each earlier tier is independently testable and demonstrable. + +--- + +## Problem Frame + +Stackmap already makes branch topology, worktree ownership, committed diffs, pull requests, and cleanup safety legible. It currently cannot answer: + +- Which agent sessions are operating in each worktree? +- Are multiple agents claiming the same branch? +- What did each agent explicitly say it is doing? +- Is a session active, waiting, idle, stale, or handed off? +- Did Git actually change while that activity was present? +- Can a new agent orient itself without reconstructing the repository through several commands? + +Git cannot answer the lifecycle or intent questions, while an agent hook cannot prove authorship, progress, or success. The implementation must preserve those two evidence classes separately. A live lifecycle plus a dirty worktree means “an agent is present and Git has changes,” not “this agent authored these files.” + +--- + +## Requirements Trace + +This plan preserves the actor, flow, requirement, and acceptance-example IDs from the origin document. + +| Origin requirement | Planned realization | +|---|---| +| R1-R2 | U1-U2: first-class per-worktree evidence and versioned repository status | +| R3-R7 | U3: provider-neutral registry, lifecycle, claims, handoffs, provenance, and expiry | +| R8-R9 | U6: installable Codex desktop plugin and lifecycle adapter | +| R10 | U7: installable Claude Code plugin covering sessions, subagents/teams, tasks, and worktrees | +| R11 | U3 and U5: generic CLI writes/queries and shared MCP tools | +| R12-R13 | U4: responsive main-view integration and safe degradation | +| R14-R16 | U3 and U5-U7: workflows, advisory-only writes, strict privacy allowlists | +| R17-R18 | Every unit, with cross-surface verification concentrated in U8 | + +Key flows: + +- F1 is established by U3 and completed for Codex/Claude by U6-U7. +- F2 is established by U3 and made agent-usable through U5-U7. +- F3 depends on U1, U3, and U4 preserving Git-observed evidence separately. +- F4 depends on U3 lifecycle leases and U5 handoffs, then provider mappings in U6-U7. +- F5 is delivered and proven in U6-U8. + +Acceptance examples: + +- AE1: U1, U4, U6, U8 +- AE2: U3, U4, U6, U7, U8 +- AE3-AE4: U3-U4, U8 +- AE5: U5-U6, U8 +- AE6: U3, U5, U7, U8 +- AE7: U4, U8 +- AE8: U3, U5-U8 + +--- + +## Scope Boundaries + +### In scope + +- A one-shot, versioned repository status contract. +- Independent cleanliness for every occupied worktree. +- Provider-neutral lifecycle, participant, claim, intent, phase, blocker, and handoff records. +- First-class Codex desktop and Claude Code integrations. +- Main sessions, subagents, and Claude team/task signals where the installed provider exposes them. +- A documented generic CLI and MCP interface for other local agents. +- Agent cues and details in Stackmap's existing main topology view. +- Local installation, packaging, automated verification, and real-product manual validation. + +### Out of scope + +- Hard branch, stack, worktree, or file locks. +- Automatic discovery of an unintegrated runtime. +- File-level authorship, cursor location, token counts, or progress percentages. +- Inferred intent, success, completion, readiness, or review verdict. +- Prompt, transcript, private application database, terminal, command, response, or file-content scraping. +- Agent-facing Git, Graphite, GitHub, worktree, PR, or remote mutations. +- Cross-machine or cross-user synchronization. +- A separate agent dashboard. +- Cross-project aggregation implementation; it has a separate plan at `docs/plans/2026-07-28-002-feat-cross-project-agent-overview-plan.md`. + +--- + +## Context & Research + +### Relevant repository patterns + +- `src/main.rs` owns a hand-written `OsString` parser. The `agent` namespace must preserve current repository-path grammar, including option-shaped paths after `--`. +- `src/adapters/command.rs` already centralizes bounded, passive subprocess execution with `GIT_OPTIONAL_LOCKS=0`. +- `src/adapters/git.rs` currently enumerates branch worktree paths with `for-each-ref`, but detached and unborn worktrees require `git worktree list --porcelain -z`. +- `src/adapters/git/inventory.rs` has one repository-level `dirty: bool`; `src/refresh/builder.rs` assigns it only to the launching checkout's current branch. +- `src/refresh/watcher.rs` watches the Git common directory but only recognizes `stackmap/config.toml` under Stackmap-owned metadata. +- `src/ui/layout.rs` preserves a 40-column minimum, an eight-cell branch-name minimum, fixed metadata gutters, and a detail sidebar only at 120+ columns. +- The dirty worktree has broad in-progress UI and refresh changes. Implementation must preserve it, classify which changes are publishable prerequisites, use isolated worktrees, and assign one owner to each overlap hotspot. + +### Provider research + +Codex desktop currently supports plugins that package skills, stdio MCP servers, and trusted lifecycle hooks. Plugin changes apply to new chats after the relevant reload/restart, and hook trust is separate from plugin installation. Hook commands are synchronous, may overlap, and must remain fast. `SessionEnd` can be delayed, so explicit leases are required. + +Claude Code 2.1.220 is installed locally. Its plugin surface supports `.claude-plugin/plugin.json`, `skills/`, `hooks/hooks.json`, `.mcp.json`, plugin-root/data variables, local `--plugin-dir` testing, validation, and marketplace installation. Current hooks expose session, permission, tool, subagent, task, teammate, working-directory, file-change, worktree, compaction, stop, and session-end events. Provider event payloads contain sensitive fields that the Stackmap adapter must ignore. + +### Institutional decisions carried forward + +- The earlier agent-status plan correctly chose a purpose-built DTO, per-worktree tri-state evidence, bounded one-shot collection, and passive reads. +- This plan supersedes its deferral of progress annotations and MCP because the product goal now explicitly requires live agent coordination. +- Repository-local state lives under the Git common directory so all linked worktrees share one coordination rendezvous. +- Per-session atomic records avoid a high-contention shared heartbeat file and isolate malformed writers. + +--- + +## Key Technical Decisions + +| Decision | Rationale | Rejected alternative | +|---|---|---| +| CLI collector is the data-plane foundation | TUI, MCP, skills, hooks, and generic agents share one bounded implementation | Let every integration rediscover Git independently | +| Domain model is provider-neutral | Codex and Claude remain adapters rather than defining core semantics | Encode Codex hook fields in UI/runtime models | +| Coordination writes are advisory only | Visibility improves without becoming a second Git authority | Hard locks or checkout prevention | +| Git and lifecycle evidence remain separate | Neither source can safely prove the other | Infer “editing” or “done” from hook or dirty state | +| Records are per participant/session and compare-and-replaced under a bounded participant lock | Concurrent hooks degrade independently while ordering guards prevent released sessions from being resurrected | Shared mutable heartbeat JSON or unguarded last-writer-wins replacement | +| Main view gets progressive disclosure | Branch ownership is visible where topology decisions happen | Separate agent dashboard | +| Semantic intent is explicit | Hook payloads cannot be mined safely or reliably | Parse prompts, commands, transcripts, or responses | +| Codex and Claude package the same canonical workflows | Behavior stays consistent across providers | Maintain divergent skill copies by hand | +| Use provider plugins, not private app APIs | Supported installation surfaces are testable and distributable | Scrape terminals, databases, or experimental App Server state | +| Use a maintained MCP protocol library behind a narrow adapter | Handshake/version behavior stays compatible while the domain remains independent | Hand-roll an expanding protocol in core modules | + +### Activity identity and storage + +- Repository identity is a domain-separated SHA-256 digest over a platform-tagged, length-prefixed canonical Git common-directory representation. Native path bytes are used where available; serialization never depends on lossy display text. +- Every provider supplies an opaque session ID transiently. Stackmap stores only a repository-scoped SHA-256 digest of provider plus session identity; raw provider session IDs never reach disk, output, diagnostics, or rendering. +- Subagents and teammates are separate participants with a parent session reference, not just a count. Aggregated counts are derived. +- Records live beneath `/stackmap/activity/v1/participants/`. +- Handoffs live beneath `/stackmap/activity/v1/handoffs/`. +- Writers serialize only an explicit allowlist. Unknown input fields are discarded before any logging or persistence. +- Provider `cwd` is an allowlisted transient resolution input. Only validated repository/worktree identity is persisted; transcript paths and unrelated file paths are discarded. +- Explicit semantic fields are not copied from provider payloads. `phase`, `readiness`, and blocker category are enums; intent and handoff summaries are single-line, control-free, terminal-safe UTF-8 capped at 160 bytes. The privacy guarantee covers automatic provider capture; explicitly submitted semantic text is intentional agent-reported input. + +### Ordering and caller identity + +- Every record has a monotonic local revision and the strongest provider ordering key available (`turn_id`, `prompt_id`, child/task ID, and event class). +- Writers perform a bounded read/compare/write under a per-participant coordination lock. Terminal events outrank earlier same-turn events; a released tombstone rejects delayed non-start events. +- `SessionStart` startup/resume/fork establishes a new accepted generation. Duplicate events are idempotent. +- Only startup/CWD events may replace the persisted worktree path; ordinary tool events re-resolve the current HEAD within that proven worktree but cannot regress an explicit CWD change. +- Provider-specific hook entrypoints emit byte-exact neutral stdout and a provider-compatible fail-open status. Generic JSON CLI output is never reused as hook stdout. +- `SessionStart` is the only intentional exception: after Gate B proves the provider contract, it emits the provider's exact fixed JSON context shape containing non-sensitive coordination instructions and the opaque participant handle. Every other lifecycle hook emits byte-exact neutral stdout. If either provider cannot deliver fixed startup context reliably, Gate B must resolve another supported session-binding/instruction surface before automatic semantic reporting remains a release criterion. +- A provider hook creates a session-scoped participant capability. Wave 0 must prove whether each product can bind its MCP process directly to that capability; otherwise the startup hook supplies an opaque handle through supported session context. Semantic MCP writes require the capability and can mutate only that participant. Ambiguous or unbound calls fail without writing. A separate explicit operator repair command may act across participants. +- This capability boundary prevents accidental cross-session writes; it is not an authorization boundary against another malicious process running as the same operating-system user. + +### Lifecycle leases + +Provider hooks are event-driven rather than guaranteed heartbeats. The first release separates recent observation from retention: + +- `recent`: an active/working/waiting event observed within five minutes. +- `aging`: observed five to fifteen minutes ago; still shown with age but not presented as freshly active. +- `unconfirmed`: observed fifteen to sixty minutes ago; excluded from live counts/collision attention and rendered with `?` while retaining the last lifecycle label. +- `idle`: retained for 35 minutes, covering delayed Codex session-end behavior without implying work. +- `released`: removed from live presence immediately. +- After sixty minutes without a supported event, non-idle records become `stale`; stale records remain inspectable for four hours before deletion. +- A soft claim has its own four-hour advisory lease and remains visible to preflight/detail after lifecycle activity becomes idle, unconfirmed, or stale. Recent overlapping claims produce `C`; non-live overlapping claims produce a lower-priority `c` caution. Claims never block work and explicit release/handoff ends the participant's claim. +- Explicit handoffs remain for 24 hours unless released sooner. + +Compact rows encode freshness category; exact last-seen age is always available in detail and appears inline only when width permits. An expiry scheduler computes the next lease/retention deadline and refreshes even when no filesystem event occurs. Writers and active coordinators perform bounded garbage collection; no background daemon is required. Durations are constants behind an injected clock and test-only override, not user-editable configuration in the first delivery. + +### Responsive activity geometry + +Activity reuses the existing metadata budget rather than moving the topology/name boundary. Disclosure is based on the actual branch-map body width, not the outer terminal width; a wide terminal with the detail sidebar open may have a narrow body. + +| Branch-map body width | Coordination allocation | Activity disclosure | +|---|---|---| +| 40-63 | Diff 7, gap 1, worktree 2, activity 3 | Fixed badge grammar below | +| 64-89 | Preserve the current total metadata width; time 6, gap 1, diff 7, gap 1, worktree 2, remaining activity | Provider/count plus bounded phase, such as `C1 test` | +| 90-119 | Preserve `metadata_start`; activity takes optional PR/time space before topology/name/worktree evidence | Provider/count and bounded phase/attention label | +| 120+ without sidebar | Diff 7, gap 1, worktree 10, gap 1, remaining activity | Truncated provider/phase/intent summary | +| 120+ with sidebar | Recompute from the narrower body width | Compact row; pane shows complete bounded values | + +With no readable activity registry, rendering stays on the existing geometry path. With activity, the coordination allocation has the same total metadata width and `metadata_start` as the existing allocation at that body width, so zero-to-one and lifecycle transitions do not move lanes, connectors, or branch names. Optional PR/time evidence yields before topology, minimum branch-name width, worktree ownership, or agent evidence. Rows never wrap and topology overflow rules remain authoritative. + +Compact badges are `` padded to three cells. Counts include recent/aging live participants only; `+` means ten or more. Recent state precedence is `C` live collision, `B` blocked, `W` waiting, and `A` active. Lowercase `c`, `b`, `w`, and `a` mean the same state is aging rather than recent. `I` means idle, `?` unconfirmed, `S` stale-only, `R` a retained ready handoff whose Git preconditions still match, and `h` a retained handoff whose Git evidence drifted. Handoffs and stale participants never inflate the live count. Wider rows use `Cx` for Codex, `Cl` for Claude, and `G` for generic providers before count/phase. + +Seven-cell diff rendering retains distinct compact additions/deletions (`+…` and `-…`) for small, abbreviated-thousands, loading, and unavailable states; exact values remain in detail/status. Rendering fixtures prove this does not collapse committed diff evidence. + +--- + +## Open Questions + +### Resolved during planning + +- **Are Claude and non-Codex agents part of success?** Yes. Real Codex and Claude integrations are required. Other agents use the generic contract. +- **Can Stackmap attribute dirty files to an agent?** No. It shows co-occurring but separately labelled lifecycle and Git evidence. +- **Where is repository-local coordination stored?** Under the Git common directory in independent atomic files. +- **Does ownership block work?** No. Claims are soft and collisions are visible. +- **How are stale sessions handled?** State-specific leases, stale exclusion, bounded retention, and explicit age. +- **Where does the UI live?** Inside the ordinary topology view with selected-row detail. +- **Should Stackmap depend on Codex's internal APIs?** No. Supported plugin, MCP, hook, and skill surfaces only. +- **How are binaries found from GUI-launched plugins?** Release packages bundle target-specific launchers/binaries; development installers write an absolute executable path. `PATH` is an optional fallback, never the only path. + +### Deferred to implementation spikes + +- Wave 0 must confirm the exact Codex desktop marketplace/trust flow, hook stdout/context behavior, per-chat MCP binding, and new-chat activation before U3 freezes provider assumptions. +- Wave 0 must capture real Claude 2.1.220 plugin/hook/MCP fixtures, team/task field availability, session binding, and fail-open behavior before U3 freezes provider assumptions. +- Select the maintained Rust MCP crate/version after a minimal handshake spike and dependency-policy check. +- Tune compact activity glyphs with rendering fixtures while preserving the disclosure contract above. + +These spikes can change packaging or field mapping, but not the success criteria, privacy boundary, provider-neutral model, or required real-product validation. + +--- + +## Pre-Implementation Gates + +### Gate A: preserve and classify the dirty checkout + +Before U1, preserve the current dirty primary checkout without staging, switching, or cleaning it: + +1. Capture HEAD, branch, porcelain-v2 status, staged and unstaged binary patches, untracked paths, and file checksums into a temporary baseline directory. +2. Create an isolated worktree from the current HEAD and reproduce the tracked and untracked state there. +3. Verify hashes and diff equivalence. +4. Inventory ignored-but-required files, nested repositories/submodules, sparse-checkout state, symlinks, executable modes, and unsupported special files; explicitly preserve or exclude each class. +5. Create a local preservation commit only in the isolated worktree and re-verify that the primary checkout's branch, HEAD, index, and file hashes did not change. +6. Review the preserved diff and split relevant existing work into publishable prerequisite commits. Implementation branches may descend only from approved prerequisite commits; otherwise they start from the clean base and port only necessary hunks. +7. Record an ancestry check proving no implementation commit depends on an intentionally unpublished preservation-only commit. + +No implementation unit begins until preservation equivalence and the publishable-base decision pass. + +### Gate B: prove real provider surfaces + +Before U3 freezes the provider-neutral contract: + +1. Build a disposable minimal launcher/hook/MCP echo artifact with no production registry code. +2. Install it through the real Codex local marketplace, trust hooks, start a new task, and prove byte-exact hook firing, supported session context, and whether MCP is per-task bound. +3. Load the equivalent Claude plugin with `--plugin-dir`, validate/reload it, start a real session/subagent, and capture sanitized fixtures for every contract-critical event. +4. Exercise missing binary, non-zero/timeout, and disabled/untrusted hook cases and prove both providers continue the agent turn. +5. Delete the disposable artifact after recording only sanitized schema/behavior fixtures. + +A provider surface failure changes the adapter/identity design before U3; it does not justify private API or transcript scraping. + +--- + +## High-Level Technical Design + +This sketch is directional. Unit-level tests and existing repository abstractions govern exact APIs. + +```mermaid +flowchart TB + CODEX["Codex desktop plugin"] --> INGEST["Provider hook ingestion"] + CLAUDE["Claude Code plugin"] --> INGEST + OTHER["Generic agent CLI"] --> REGISTRY["Provider-neutral registry"] + INGEST --> REGISTRY + GIT["Bounded Git/worktree collector"] --> STATUS["Versioned status DTO"] + REGISTRY --> ACTIVITY["Activity snapshot + attention"] + STATUS --> MCP["Shared MCP tools"] + ACTIVITY --> MCP + STATUS --> TUI["Stackmap main topology"] + ACTIVITY --> TUI + SKILLS["Orient / coordinate / preflight / handoff skills"] --> MCP +``` + +Repository reconciliation follows this evidence rule: + +```text +provider event + -> parse only provider-specific allowlisted identity/lifecycle fields + -> resolve cwd to repository + exact worktree + HEAD state + -> atomically replace that participant record + +TUI or query + -> collect current Git/worktree truth + -> load bounded registry records independently + -> expire/reclassify records using injected clock + -> reconcile by repository/worktree/branch/OID + -> render lifecycle and Git observations as separate evidence +``` + +Implementation dependencies: + +```mermaid +flowchart TB + SAFE["Gate A preservation"] --> U1["U1 Worktree evidence"] + PROVIDER["Gate B provider proof"] --> U3A["U3a Participant registry"] + U1 --> U2["U2 Status contract"] + U2 --> U3A + U3A --> U3B["U3b Claims + handoffs"] + U3B --> U4["U4 Main-view visible slice"] + U4 --> U5["U5 MCP + workflows"] + U5 --> U6["U6 Codex plugin"] + U5 --> U7["U7 Claude plugin"] + U4 --> U6 + U4 --> U7 + U4 --> U8["U8 End-to-end release"] + U6 --> U8 + U7 --> U8 +``` + +--- + +## Implementation Units + +- [ ] U1. **Model trustworthy evidence for every occupied worktree** + +**Goal:** Replace the launching-checkout-only dirty boolean with a first-class, bounded worktree inventory that can support both machine status and human rendering. Realizes F3 and enforces AE1. + +**Dependencies:** Pre-Implementation Gate A. + +**Files:** + +- Create `src/model/worktree.rs`. +- Modify `src/model/mod.rs`, `src/model/branch.rs`. +- Modify `src/adapters/git.rs`, `src/adapters/git/inventory.rs`. +- Modify `src/refresh/builder.rs`, `src/refresh/diffstats.rs`. +- Modify common test builders in `src/integration_tests/common.rs`. +- Extend `src/integration_tests/repository_snapshot.rs`, `refresh_pipeline.rs`, and `navigation_checkout.rs`. + +**Approach:** + +- Parse `git worktree list --porcelain -z` into explicit primary/linked records, including branch, detached, unborn, prunable, and operation state. +- Preserve exact worktree path bytes or mark a documented lossy representation at serialization boundaries. +- Run existing passive status machinery once per distinct accessible worktree with bounded concurrency, timeout, and output. +- Model cleanliness as `clean`, `dirty`, or `unavailable { reason }`; never collapse unavailable to clean. +- Associate branches with worktree IDs while allowing worktrees without a local branch. +- Compute a stable source token from worktree identity, HEAD/OID, branch/detached state, operation state, and cleanliness; keep observation time outside equality fingerprints. +- Use a bounded pre/post coherence barrier around the complete worktree set: inventory identity/HEADs, collect statuses, repeat the full status/evidence read, and publish only when identity, HEAD, index/operation state, tracked/untracked cleanliness, and worktree set match. Retry the complete set a bounded number of times; U2 owns exit `3` after exhaustion. + +**Test scenarios:** + +- Clean primary and clean linked worktree map independently. +- Dirty primary and dirty linked worktrees mark only their owning rows; committed diffstats do not change. +- Detached and unborn worktrees remain visible without invented branch ownership. +- A removed, unreadable, timed-out, or prunable worktree degrades to unavailable. +- Concurrent reads remain bounded and do not create index locks, fetch, or orphan processes. +- Branch switches, worktree moves/removal, and index changes during each collection phase either yield one coherent retry result or typed instability. +- Existing checkout/delete protections still refuse branches owned by linked worktrees. + +**Verification outcome:** Every occupied worktree has trustworthy modeled cleanliness and exact coherent HEAD evidence; existing passive Git invariants remain intact. Rendering completion belongs to U4/U8. + +- [ ] U2. **Expose a versioned repository-status collector and CLI** + +**Goal:** Give every consumer one deterministic repository/topology contract rather than serializing TUI runtime state. Realizes R1, R2, and the repository half of R14. + +**Dependencies:** U1. + +**Files:** + +- Create `src/agent/mod.rs`, `src/agent/status.rs`, `src/agent/report.rs`, `src/agent/path.rs`. +- Modify `src/lib.rs`, `src/main.rs`, and `src/config.rs`. +- Create `src/integration_tests/agent_status.rs`. +- Create `tests/fixtures/agent/status-schema-v1.json`. +- Update `src/integration_tests/mod.rs`. + +**Approach:** + +- Add the unambiguous namespace `stackmap agent status [OPTIONS] [REPOSITORY]` while preserving legacy TUI grammar and `--` paths. +- Keep collector types private and map them into explicit schema-v1 DTOs. +- Include repository identity/root, capture time, source fingerprint, exact OIDs, topology/provenance, worktrees/cleanliness, committed diffs, provider health, and optional presentation metadata. +- Sort arrays deterministically and bound all strings, lists, subprocess work, retries, and output. +- Emit machine-clean JSON on stdout and diagnostics on stderr. +- Exit `0` for a coherent report including typed optional-provider degradation, `2` for invocation/discovery failure, and `3` when consistency retries cannot produce one coherent source snapshot. + +**Test scenarios:** + +- A real multi-stack/multi-worktree repository matches the golden schema and ordering with an injected capture clock; production capture time is metadata, not part of semantic determinism. +- Dirty, detached, unborn, missing provider, and unavailable worktree variants remain typed. +- Concurrent ref/config/worktree changes either produce one coherent snapshot or exit `3`. +- Repository paths named `agent`, `status`, or option-like strings remain addressable. +- Broken stdout, non-UTF-8 paths, and oversized optional data fail explicitly. +- The command performs no fetch, lock, index refresh, or mutation. + +**Verification outcome:** CLI consumers can obtain one bounded, deterministic view of the same repository facts the TUI uses. + +- [ ] U3. **Add the provider-neutral activity registry and generic agent CLI** + +**Goal:** Establish lifecycle, participant, soft-claim, intent, attention, and handoff semantics before adding provider-specific adapters. Realizes F1-F4, R3-R7, R11, R15-R17, AE2-AE4, and the generic half of AE6. + +**Dependencies:** U2. + +**Files:** + +- Create `src/agent/activity/mod.rs`, `contract.rs`, `registry.rs`, `attention.rs`, `clock.rs`. +- Create `src/agent/providers/mod.rs`. +- Modify `src/agent/mod.rs`, `src/lib.rs`, `src/main.rs`. +- Create `src/integration_tests/agent_activity.rs`, `activity_concurrency.rs`, and `activity_privacy.rs`. +- Create `tests/fixtures/agent/activity-schema-v1.json`. + +**Approach:** + +- Deliver U3 in two sequential, independently verified checkpoints: + - **U3a:** stable digests, participant identity/capability, event ordering, lifecycle/freshness, atomic registry, bounded reads, generic presence/report/release, and provider-neutral sanitized fixtures. + - **U3b:** claims, collisions, attention, handoffs, Git reconciliation, retention/cleanup, overload behavior, and privacy/terminal contract completion. +- Define a persisted internal schema-v1 participant record with provider, session fingerprint, scoped capability metadata, optional parent, repository/worktree identity, branch/detached/unborn state, observed OID, lifecycle, ordering key/revision, lease, last-seen, provenance, bounded reported intent/phase/blocker, and claim targets. +- Define separate public CLI/MCP/TUI DTOs that never serialize capability handles/hashes, internal compare-and-replace revisions, provider ordering keys, or tombstone internals. +- Define explicit handoffs with before/after OIDs, bounded verification summary, readiness/blocker, source participant, and expiry. +- Expose generic JSON-stdin CLI operations to create a self-scoped participant capability, claim, report lifecycle/intent/phase, create handoff, release self, list activity, and query attention. Cross-participant repair is a separate explicit operator command. +- Resolve nested/symlinked CWDs to exact worktrees. Deleted, outside-repository, and ambiguous inputs remain unassigned or fail explicitly. +- Use per-participant bounded locks, compare-and-replace ordering guards, same-directory exclusive temporary files, sync/close, and atomic rename. Released tombstones prevent delayed-event resurrection. +- Reject symlink/non-regular Stackmap path components and destinations; require current-user ownership; create directories `0700` and files `0600`; sync file and parent around rename. Reject hostile mode/ownership rather than following a redirected path. +- Cap each record at 32 KiB, participants and handoffs at 256 each, and directory inspection at 1,025 entries. Writers clean expired records before admission. If inspection proves the directory exceeds the cap, publish `incomplete/overloaded` health instead of trustworthy counts; never silently present a partial set as complete. +- Reconcile stored branch/OID evidence against current Git truth without rewriting history or attributing changes. +- Normalize strings before persistence and again at output sinks. Reject C0/C1, escape/CSI/OSC, CR/LF/tab, bidi controls, NUL, excessive combining/zero-width sequences, and oversized UTF-8; JSON serialization may escape but never render raw terminal controls. +- Allowlist persisted and emitted fields; discard raw provider input before diagnostics. Privacy canaries cover every discarded provider field and every explicit semantic-report field, with the latter documented as intentional input rather than automatic capture. +- Add the stable SHA-256 dependency in U3; U3 and U5 share sequential ownership of `Cargo.toml`/`Cargo.lock`. + +**Test scenarios:** + +- Two generic sessions claim separate worktrees and one shared branch; collisions aggregate without blocking. +- Resume updates one record; parent and two children end out of order without corrupting counts. +- Branch switch, detached HEAD, deleted branch, removed worktree, and stale expected OID remain truthful. +- Explicit release is immediate; crash expiry passes through stale retention; handoffs expire independently. +- Concurrent writers/readers never lose or cross-wire participants. +- Delayed active after release, branch-switch before delayed tool, duplicate events, child-stop before child-start replay, and clock rollback cannot regress state. +- One session cannot use MCP/CLI capability data to update or release another; same-worktree ambiguity refuses. +- Public list/status/attention/TUI serialization never exposes capability or ordering internals. +- Malformed, oversized, unknown-version, and permission-denied records degrade independently. +- First registry creation is observable later by U4; symlink swaps, hostile umasks/modes, directory floods, full/read-only storage, and compare-before-delete races fail safely. +- Unique canaries placed in discarded provider prompt, transcript, command, tool response, assistant message, approval text, and file-content fields never appear in storage, CLI output, logs, or snapshots. Explicit semantic fields are tested separately as intentional bounded input. + +**Verification outcome:** Any local agent can safely publish/query advisory coordination, and Stackmap has one provider-independent source for all integrations. + +- [ ] U4. **Integrate reconciled agent activity into the main topology view** + +**Goal:** Answer “who is on this branch, what did they report, and what does Git show?” without leaving the existing main view. Realizes R12-R13 and AE1-AE4, AE7. + +**Dependencies:** U3a for scaffolding; U3b before the first visible-slice gate closes. + +**Files:** + +- Create `src/model/activity.rs`, `src/activity/mod.rs`, `src/activity/watcher.rs`, `src/activity/coordinator.rs`, `src/app/activity.rs`, and `src/ui/tree/activity.rs`. +- Modify `src/model/mod.rs`, `src/app.rs`, `src/app/state.rs`, `src/events.rs`, and `src/main.rs`. +- Keep activity out of `Branch`, `RepositorySnapshot`, and the Git-generation coordinator in `src/refresh/mod.rs`. +- Modify `src/ui/layout.rs`, `src/ui/tree.rs`, `src/ui/tree/details.rs`, `src/ui/panels.rs`, `src/ui/theme.rs`. +- Extend `src/integration_tests/refresh_pipeline.rs`, `tui_rendering.rs`, `topology_layout.rs`, and `terminal_interaction.rs`. + +**Approach:** + +- Watch the nearest existing Stackmap/common-directory parent until the activity directory exists, then rebind to finalized activity and handoff records. Ignore temporary files. Use an independent bounded watcher/coordinator and latest-snapshot queue so registry failure cannot block or overwrite structural/diff refresh. +- Schedule the nearest freshness/lease/handoff/retention deadline, rebuild without filesystem events, and perform bounded compare-before-delete cleanup. +- Precompute an `ActivityIndex` keyed by branch and worktree, with unassigned/hidden counts and O(1) row summaries. Application state owns it beside, not inside, the Git snapshot. +- Use the responsive geometry contract above and suppress optional PR/time data before weakening topology, minimum name width, worktree ownership, or agent cues. +- Aggregate multiple participants and retained handoffs while preserving a discoverable selected-row list ordered by live attention, live activity, handoff, idle, then stale. A newer live participant never gets overwritten by an older handoff; retained ready/blocked handoffs remain separate evidence and affect the row only when higher-priority live attention is absent. +- A ready handoff renders `R` only while current branch OID and required cleanliness match its recorded after-state. Commits, resets, rebases, branch deletion, or new dirty work downgrade it to historical/mismatched handoff evidence in detail. +- Render non-color markers for active, waiting, idle, stale, blocked, ready, collision, and unassigned states. +- Label `agent reported` and `Git observed` explicitly in detail. Dirty/tip evidence without an agent remains `unattributed`. +- If activity and Git evidence capture times differ by more than five seconds, request at most one bounded in-flight Git refresh per five-second cooldown and show both capture ages until they converge; never imply causal co-occurrence from stale snapshots. +- Reuse `d` as branch detail: it toggles the existing sidebar when wide and opens a bounded detail overlay at narrow/medium widths. The overlay groups parent/children and handoffs, supports `j/k` or arrows, shows a capped visible list plus `+N`, preserves its participant selection across redraw/resize, and closes with `Esc`. +- Preserve filter/focus semantics while showing a bounded `N hidden agents` cue and one-step detail. Detached, deleted, or renamed branch claims stay unassigned rather than attaching by name. +- Render hidden/unassigned counts in the main header/status treatment; `D` opens a global coordination detail overlay grouped as hidden, archived, detached/unassigned, and stale, and `Esc` returns to the prior branch selection. +- An active claim on an archived branch remains discoverable through a hidden/archived cue and detail without mutating archive configuration. +- Preserve selection, scroll, lane geometry, branch ordering, archive/filter state, and all mutation guard behavior as activity changes. +- Retain the last valid activity snapshot only while its entries naturally age; unreadable registry health never freezes participants as live. + +**Test scenarios:** + +- At 40, 64/80, 90, and 120+ columns, zero/one/multiple agents never wrap or displace required topology. +- Identical topology rendered with and without activity keeps the same `metadata_start`, lane, connector, and branch-name positions. +- Codex and Claude on one branch render a count/collision and list both identities. +- Live overlapping claims render `C`; idle/unconfirmed/stale overlapping unexpired claims remain visible as `c` in preflight/detail until released or their claim lease expires. +- Active, waiting, idle, stale, blocked, ready, and unassigned transitions update in place. +- A ready handoff downgrades immediately when OID or cleanliness no longer matches its recorded after-state. +- Reported testing without Git changes and dirty Git without a reporting agent remain distinct. +- Filtered, focused, archived, detached, deleted, and renamed claims increment hidden/unassigned attention without fabricated association. +- A 120+ terminal with the detail sidebar uses the actual narrowed body width for row disclosure. +- Missing registry, invalid version, denied read, or activity overload produces one bounded degradation notice and an otherwise normal TUI. +- PTY resize and live record replacement preserve selection and do not leak alternate-screen/terminal state. +- Starting the TUI before any Stackmap metadata exists and then writing the first participant causes a redraw. +- `NO_COLOR` selected/current/unselected fixtures preserve every state, collision, freshness, and degradation cue. + +**Sequential U4 checkpoints (one UI owner):** + +1. Activity watcher/coordinator/index and registry health. +2. Compact row association/count/collision with stable geometry. +3. Medium/wide provider, phase, intent, and Git-evidence disclosure. +4. Selected branch sidebar/narrow overlay. +5. Hidden/archive/unassigned global detail. +6. Resize, expiry-without-events, degradation, and PTY hardening. + +Each checkpoint must compile and pass its focused state/render tests before the next. + +**Verification outcome:** Agent presence is always discoverable in the normal branch map, with deeper context available in one selection and no regression when integrations are absent. + +- [ ] U5. **Expose shared MCP tools and canonical agent workflows** + +**Goal:** Let supported agents orient, coordinate, preflight, report, hand off, and release through the same collector/registry used by the TUI. Realizes F2, R11, R14-R17, AE5-AE6. + +**Dependencies:** U3b. Begins after U4's first visible-slice checkpoint to avoid shared `src/main.rs` ownership. + +**Files:** + +- Create `src/agent/mcp.rs`, `src/agent/tools.rs`. +- Modify `src/agent/mod.rs`, `src/lib.rs`, `src/main.rs`, `Cargo.toml`, and `Cargo.lock`. +- Create `src/integration_tests/agent_mcp.rs` and bounded fixtures under `tests/fixtures/mcp/`. +- Predeclare provider test ownership through `src/integration_tests/providers/mod.rs`, `providers/codex.rs`, and `providers/claude.rs`; U6 and U7 modify only their provider file. +- Create canonical skills: + - `integrations/shared/skills/stackmap-orient/SKILL.md` + - `integrations/shared/skills/stackmap-coordinate/SKILL.md` + - `integrations/shared/skills/stackmap-preflight/SKILL.md` + - `integrations/shared/skills/stackmap-attention/SKILL.md` + - `integrations/shared/skills/stackmap-handoff/SKILL.md` +- Create `docs/agent-integration.md`. + +**Approach:** + +- Add `stackmap agent mcp` as a bounded stdio server behind a maintained protocol adapter. +- Read tools: repository status, worktree activity, branch detail, and attention items. +- Advisory write tools: claim work, report intent, report phase/blocker, create handoff, and release claim. +- Provider MCP servers bind writes to the capability proven in Gate B. Read tools can use launch roots; unbound/ambiguous writes fail without mutation. +- Every repository fact comes from U2; every coordination write goes through U3. +- Tool descriptions explicitly distinguish observed facts from agent-reported claims. +- Skills compose the tools into orient, coordinate, preflight, attention, and handoff workflows without reimplementing Git discovery. +- Skill descriptions and supported startup context instruct a newly started coding task to orient, claim, report bounded intent, and update phase automatically. The UI explicitly falls back to `intent not reported`; no provider prompt is parsed. +- Automatic semantic reporting performs one orient/claim at task start and writes only on meaningful phase/attention transitions, targets no recurring approval prompts after trusted install, and is bounded to six coordination writes per task unless the agent explicitly hands off. A documented repository/session opt-out switches to presence-only visibility. +- Package scripts copy canonical skill sources into provider artifacts and verify equality; provider directories are not edited as independent workflow sources. + +**Test scenarios:** + +- MCP initialize, tool discovery, calls, shutdown, malformed request, unknown method, and broken stdout behave correctly. +- Nested-directory and non-Git launches resolve or degrade explicitly. +- CLI and MCP return semantically equivalent status/activity. +- Two same-provider sessions in one worktree cannot cross-write intent/phase/release; unbound calls refuse. +- Write tools mutate only Stackmap advisory metadata. +- Bounded request size, response size, execution time, and concurrent calls hold. +- Skills preserve privacy and revalidate Git state before giving preflight advice. + +**Verification outcome:** Codex, Claude, and generic MCP clients share one safe coordination vocabulary and cannot mutate repository state through Stackmap. + +- [ ] U6. **Package and prove the Codex desktop integration** + +**Goal:** Make new Codex desktop tasks and their subagents visible on the correct Stackmap rows through supported plugin surfaces. Realizes F1, F5, R8-R9, AE1-AE2, AE5. + +**Dependencies:** U3b for lifecycle packaging, U4 for visible proof, and U5 for final MCP/skills packaging. + +**Files:** + +- Create `src/agent/providers/codex.rs` and provider hook fixtures under `tests/fixtures/hooks/codex/`. +- Modify the predeclared `src/integration_tests/providers/codex.rs`. +- Create plugin source under `integrations/codex/`: + - `.codex-plugin/plugin.json` + - `.mcp.json` + - `hooks/hooks.json` + - `bin/stackmap-launcher` + - `README.md` +- Create `scripts/package-codex-plugin.sh`, `scripts/verify-codex-plugin.sh`, and `scripts/install-codex-plugin.sh`. +- Add a repository-local marketplace entry in the supported `.agents/plugins/` shape and document personal installation. + +**Approach:** + +- Map `SessionStart` startup/resume/clear/compact idempotently; map prompt/tool/permission events to active/waiting; map `Stop` to idle and `SessionEnd` to release. +- Create separate child participants for `SubagentStart` and release the exact child on `SubagentStop`. +- Startup/resume events establish repository/worktree; ordinary prompt/tool/permission/stop events re-resolve HEAD only within the persisted proven worktree and cannot replace its path. Codex remaps only if Gate B proves an explicit supported CWD-change event. +- Ignore prompt, transcript, tool input/output, commands, approval descriptions, and assistant/subagent messages before persistence or logs. +- Keep command hooks fail-open and bounded: byte/input caps, no retries, a provider-configured hard timeout, neutral success on Stackmap absence/failure, provider-contract stdout (`SessionStart` fixed context; all others neutral), sanitized one-line stderr at most, and no full repository collection. +- Bundle target-specific binaries for releases. Development installation pins an absolute local executable. Neither hook nor MCP launch depends only on the desktop app's `PATH`. +- Match Stackmap's current release matrix for the first delivery: macOS 15+ on Apple Silicon and Intel. Other targets use a separately installed absolute binary only after their release support is designed and tested. +- Make hook trust, hook-disabled degradation, new-chat activation, and plugin change/retrust behavior visible in validation docs. + +**Test scenarios:** + +- Every supported hook fixture maps to the correct provider-neutral participant transition. +- Resume/compact does not duplicate; two children stopping out of order affects only those children. +- Waiting clears on post-tool, stop, session end, or lease expiry. +- A branch switch updates worktree/branch/OID on the same session. +- Hook concurrency/reordering and missing optional fields remain safe. +- Every hook event asserts exact stdout and exit semantics; missing/wrong-architecture binaries, timeout, read-only/full disk, permission denial, and corrupt events never block the Codex turn. +- Privacy canaries from every sensitive Codex field are absent everywhere. +- Packaged manifest, referenced paths, MCP launcher, architecture selection, and marketplace entry validate. + +**Verification outcome:** After real plugin installation and hook trust, two new Codex desktop tasks in different worktrees appear automatically, expose the workflows/MCP tools, and transition correctly in Stackmap. + +- [ ] U7. **Package and prove the Claude Code integration** + +**Goal:** Make Claude main sessions, subagents/teams, task signals, and worktree moves visible through the same semantics as Codex. Realizes F1, F5, R10, AE2, AE6. + +**Dependencies:** U3b for lifecycle packaging, U4 for visible proof, and U5 for final MCP/skills packaging. May proceed in parallel with U6 after provider-neutral contracts freeze from Gate B fixtures. + +**Files:** + +- Create `src/agent/providers/claude.rs`, `src/agent/providers/claude_view.rs`, and provider hook/listing fixtures under `tests/fixtures/hooks/claude/`. +- Modify `src/activity/coordinator.rs`, `src/main.rs`, and the predeclared `src/integration_tests/providers/claude.rs`. +- Create plugin source under `integrations/claude/`: + - `.claude-plugin/plugin.json` + - `.mcp.json` + - `hooks/hooks.json` + - `bin/stackmap-launcher` + - `README.md` +- Create `scripts/package-claude-plugin.sh`, `scripts/verify-claude-plugin.sh`, and `scripts/install-claude-plugin.sh`. +- Create a Claude marketplace manifest for repeatable installation. + +**Approach:** + +- Target and record the verified Claude Code 2.1.220 event matrix. +- Map `SessionStart` startup/resume/clear/compact/fork idempotently; prompt/tool/permission/notification enums to lifecycle; `Stop` to idle; `StopFailure` to a bounded failed-response state; `SessionEnd` to release. +- Map `SubagentStart/Stop` to separate child participants. +- Use task/team events only for IDs, lifecycle, and bounded identity labels that are documented as provider-reported. `TaskCompleted` never implies branch readiness. Never persist task subjects/descriptions or assistant messages in v1. +- Use `CwdChanged` to remap a live participant. +- A cross-repository `CwdChanged` is an ordered transfer: tombstone/release the old repository-scoped record, mint a new repository-scoped fingerprint/capability, create the new record, and expose partial failure as unassigned/transfer-incomplete rather than duplicating ownership. +- Do not register `WorktreeCreate`: in Claude it overrides default worktree creation and could mutate/block provider behavior. Do not register `WorktreeRemove` or `FileChanged` in v1; Git inventory/watching and leases already provide the needed evidence without file-path payloads. +- Reconcile the documented public `claude agents --json --cwd ` result for repository-scoped background agents not currently represented by hooks. Bound time/output/count, construct records from an allowlist, drop generated names, resolve every CWD through Git, and prefer fresher hook evidence. Never inspect Claude private daemon/team/job files. Cross-project `--all` use remains in the separate follow-up plan. +- Implement the public listing as `src/agent/providers/claude_view.rs` behind the independent activity coordinator's provider-reconciler seam. Poll at most every 15 seconds with one in flight, a 500 ms subprocess timeout, 1 MiB output cap, 256-entry cap, exponential error backoff to 60 seconds, explicit provider health, clean shutdown, and hook-over-poll freshness precedence. Read-only status/MCP queries can invoke the same bounded reconciler on demand. +- Discard transcript paths, tool inputs/outputs, commands, prompt text, task subjects/descriptions, generated agent-view names, last assistant messages, agent transcript paths, notification text, error/reason prose, background task/cron descriptions, file paths, and approval detail. +- Test locally with `--plugin-dir`, plugin validation, MCP inspection, and a marketplace-installed copy. +- Use `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` correctly; release artifacts bundle the executable and development install pins an absolute path. +- Match Stackmap's current release matrix for the first delivery: macOS 15+ on Apple Silicon and Intel. + +**Test scenarios:** + +- Every supported installed-version event maps or intentionally no-ops with a documented reason. +- Session fork creates a distinct session; resume/compact updates the existing session. +- Subagents/teammates and task lifecycle never imply completion readiness. +- CWD/worktree changes remap participants without stale branch ownership. +- No `FileChanged`, `WorktreeCreate`, or `WorktreeRemove` hook is configured in v1; ordinary Git/worktree observation remains authoritative. +- The public Claude agent-view adapter adds a background session only after Git resolution and never overrides fresher hook state. +- Every hook asserts exact provider-contract stdout/fail-open exit behavior (`SessionStart` fixed context; all others neutral); missing/wrong-architecture binaries, timeouts, read-only/full disk, and malformed input never block the Claude turn. +- Managed-hooks-only, disabled hook, missing MCP, and unsupported-version states degrade clearly. +- Sensitive Claude payload canaries never appear in registry, CLI/MCP, logs, or TUI. +- Package, plugin, marketplace, skills, MCP, and launcher validate against the real installed CLI. + +**Verification outcome:** A real Claude Code session and its supported child/team activity appear beside Codex and generic participants with the same provenance, collision, freshness, and privacy guarantees. + +- [ ] U8. **Integrate, harden, document, and release the complete coordination loop** + +**Goal:** Prove AE1-AE8 through automated and real-product validation, then make installation and testing repeatable for the user and other developers. + +**Dependencies:** U4, U6, U7. + +**Files:** + +- Create `scripts/verify-agent-integration.sh`, `docs/testing-agent-integrations.md`, and privacy fixtures under `tests/fixtures/privacy/`. +- Create `src/agent/doctor.rs` and `src/integration_tests/agent_doctor.rs`; modify `src/main.rs`, `src/lib.rs`, and `src/integration_tests/mod.rs` under the sequential core owner. +- Modify package/release verification scripts and CI/release workflows. +- Modify `README.md`, `docs/features.md`, `docs/support.md`, `docs/releasing.md`. +- Update `memory.md` and `changelog.md` after implementation and verification. + +**Approach:** + +- Add one end-to-end harness that creates real linked worktrees, runs generic hook producers, launches Stackmap in a PTY, replaces lifecycle records, changes Git state, resizes, and verifies visible transitions. +- Exercise concurrent Codex/Claude/generic claims, approval waiting, idle stop, crash expiry, explicit handoff, dirty transition, and tip movement. +- Apply privacy canaries end to end across hook input, explicit semantic fields, storage, diagnostics, status/MCP output, and rendered terminal buffers. State clearly which text was explicitly submitted. +- Add `stackmap agent doctor [--provider codex|claude]` and provider verification scripts that distinguish binary, manifest, installed, enabled/trusted where observable, hook-fired, MCP-active, and registry-readable states without inventing unavailable product state. +- Validate package contents, target launchers, manifests, canonical skill equality, and absence of development paths. +- Pin plugin/binary versions; emit release checksums/attestation metadata from the verified build; verify hashes on install/package checks; reject unexpected executables or writable launch targets; test tampering and development-path substitution. +- Measure large-repository one-shot latency, hook ingestion latency, settled TUI CPU, output/queue bounds, and orphan processes. +- Run the existing complete format, lint, unit, integration, doctest, release, no-lock, and terminal verification set. +- Perform the manual real-surface checklist below. Fixture-only evidence cannot close U8. + +**Manual acceptance checklist:** + +1. Install/reload the Codex plugin, review/trust hooks, and start two new desktop tasks in different linked worktrees. +2. Install/reload the Claude plugin and start a Claude session in another worktree. +3. Confirm all three main sessions appear on correct rows; start supported subagents and confirm distinct participants/counts. +4. Give each task a distinct ordinary coding request and confirm bundled instructions cause bounded intent/phase reporting without manually invoking a Stackmap command; record `intent not reported` as a failed adoption case, not inferred text. +5. Put one task into real permission waiting while another edits and another advances a branch. +6. Confirm lifecycle, Git-observed change, and unattributed evidence remain separate. +7. Add a Codex/Claude collision on one branch and confirm neither is blocked. +8. Stop one turn, explicitly hand off another, and crash a third; confirm idle, ready-by-report, stale, and expiry behavior. +9. Resize through compact, medium, and wide layouts and confirm stable association and topology. +10. Disable/untrust one integration and confirm Stackmap remains fully usable with one bounded degradation notice. +11. Use the production-path injected clock fixture for retention/expiry deletion; the real-product checklist requires only a feasible crash-to-unconfirmed/stale transition, not four hours of wall-clock waiting. +12. Include main-session and child/subagent/team tasks in the automatic-intent sample; distinguish child-reported intent from parent intent shown only as inherited context. + +**Verification outcome:** Automated work can reach `implementation complete and ready for operator acceptance`. `Product acceptance complete` requires the user's real Codex/Claude install/trust checklist. The user can then see all active integrated Codex, Claude, and generic local agents, ownership, intent, attention, freshness, and independent Git activity in the normal Stackmap view. + +--- + +## System-Wide Impact + +```mermaid +flowchart TB + PROVIDERS["Codex / Claude / generic providers"] --> HOOKS["Fast event adapters"] + HOOKS --> FILES["Atomic repository-local records"] + FILES --> REFRESH["Independent activity refresh"] + GIT["Git + worktree readers"] --> COLLECTOR["Shared status collector"] + COLLECTOR --> TUI["Main topology presentation"] + FILES --> TUI + COLLECTOR --> MCP["MCP query tools"] + FILES --> MCP + MCP --> WORKFLOWS["Installed skills"] +``` + +- **Interfaces:** CLI grammar, status/activity schemas, MCP tools, hook adapters, plugin manifests, watcher relevance, app events, layout geometry, row/details rendering, package/release artifacts. +- **Data lifecycle:** Provider input is allowlisted, resolved to a repository/worktree, atomically persisted, leased, reconciled, rendered/queried, marked stale, and deleted. Handoffs have an independent retention path. +- **Failure propagation:** Git discovery failure prevents repository status; optional provider/registry failures become typed degradation. One malformed participant cannot invalidate peers. TUI structural refresh remains independent. +- **Caching and consistency:** Registry snapshots are bounded point-in-time reads. Git source tokens include worktree evidence. Reconciliation labels mismatches rather than silently rewriting records. +- **Resource behavior:** Hooks never collect full status. Registry files and values are capped. Reads use bounded concurrency. Activity updates coalesce through the watcher/refresh path. +- **Security/privacy:** Repository-local coordination is writable by the local user and is advisory, not trusted authorization. Symlink/path traversal, oversized input, control characters, terminal escapes, and sensitive provider payloads require explicit tests. +- **Compatibility:** No plugin is required to use Stackmap. Existing TUI grammar and mutations remain unchanged. Unknown schema/provider versions degrade without hiding branches. + +--- + +## Agent-Native Architecture Checklist + +- **Parity:** Human rows/details and agent CLI/MCP queries consume the same collectors and registry. +- **Granularity:** Tools expose repository facts and advisory coordination primitives, not workflow-shaped Git mutations. +- **Composability:** Status, activity, attention, claim, phase, handoff, and release compose across providers. +- **Discoverability:** Plugins ship skills that teach agents when and how to query/claim/report. +- **Context efficiency:** Status is deterministic and bounded; branch/detail queries avoid dumping the whole TUI or repository unnecessarily. +- **Action safety:** Read tools are passive. Write tools touch only Stackmap metadata. +- **Provenance:** Git-observed, hook-observed, and agent-reported values remain labelled. +- **External-agent path:** Any local runtime can use the generic CLI/MCP contract without a new core subsystem. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Codex plugin/hook behavior changes or requires trust | Gate B real-app spike before U3; version fixtures; clear degradation; no private API fallback | +| Claude event fields vary by version | Record the tested 2.1.220 matrix; tolerate missing fields; do not infer absent semantics | +| Hooks slow or block agent turns | Fail-open provider-specific stdout/exit contracts, hard deadlines, one bounded parse/resolve/write, and latency/error tests | +| False freshness during long or crashed work | Recent/aging/unconfirmed categories, exact age in detail, explicit end/release, scheduled expiry, and stale retention | +| Misleading authorship | Always separate lifecycle and Git evidence; keep file activity unattributed | +| Registry corruption or hostile local content | Ownership/mode/symlink invariants, ordered compare-and-replace, directory/file caps, strict schemas, terminal sanitization, incomplete-overload health | +| MCP updates the wrong session | Gate B binding proof, participant-scoped capabilities, ambiguous-write refusal, operator-only repair | +| Linked worktree collection becomes expensive | Deduplicate paths, bound concurrency/time/output, cache only coherent evidence | +| Main-view metadata crowds topology | Activity priority table, minimum name width, optional-column suppression, exact-width fixtures | +| Provider packages drift or are substituted | Canonical shared skills, pinned versions, package-time copies, checksums/attestation metadata, strict manifest/executable verification | +| GUI-launched processes lack shell environment | Bundled binaries or installer-pinned absolute paths; `PATH` only as fallback | +| Dirty checkout work is overwritten | Mandatory preservation/equivalence gate and isolated implementation worktrees | +| MCP dependency adds runtime/size risk | Narrow adapter, pinned dependency, protocol spike, package-size and startup checks | + +--- + +## Phased Delivery and Parallelization + +1. **Wave 0 — De-risk:** Complete Gate A preservation/base classification and Gate B real Codex/Claude surface proof. +2. **Wave 1 — Trustworthy foundation:** U1, then U2, then U3a and U3b sequentially. +3. **Wave 2 — First visible slice:** U4 checkpoints under one UI/runtime owner; close with a real generic CLI participant appearing in the TUI. +4. **Wave 3 — Agent workflows:** U5 under the sequential core/dependency owner. +5. **Wave 4 — Real providers:** U6 and U7 proceed in parallel, first closing local development-install acceptance, then completing provider package verification. +6. **Wave 5 — Local morning-test milestone:** Real local Codex + Claude + generic sessions pass the multi-worktree checklist with pinned development binaries. +7. **Wave 6 — Release:** U8 completes marketplace/release artifacts, public docs, CI, security/privacy/resource hardening, and hands off for operator acceptance. +8. **Wave 7 — Operator acceptance:** The user completes the real Codex/Claude install/trust checklist, records evidence, and closes `Product acceptance complete`. + +Overlap rules: + +| Hotspot | Ownership rule | +|---|---| +| `src/main.rs`, `src/lib.rs`, `src/agent/mod.rs` | One sequential core/runtime owner across U2, U3, U4 startup wiring, then U5; U4 and U5 never run in parallel | +| `src/model/branch.rs`, `src/refresh/builder.rs`, common fixtures | U1 completes before U4 | +| `src/activity/**` versus `src/refresh/**` | Activity uses its own watcher/coordinator and never enters the Git refresh generation | +| `src/app.rs`, `src/app/state.rs`, `src/ui/**` | One U4 UI owner because the current dirty work already overlaps | +| `Cargo.toml`, `Cargo.lock` | U3 owns SHA-256/storage dependencies, then U5 owns MCP dependencies; U8 verifies only | +| Shared skills | U5 owns canonical sources; provider packaging copies, never forks | +| `src/agent/providers/mod.rs` | Predeclare provider modules before U6/U7 parallel work | +| Public docs, CI, release scripts, memory/changelog | U5 owns `docs/agent-integration.md`; U8 owns other public docs/release records and only reviews/links the U5 contract | + +--- + +## Documentation / Operational Notes + +- Document observed versus reported semantics prominently. +- Document that installing a plugin and trusting/enabling hooks are distinct. +- State supported Codex app and Claude Code versions in integration READMEs and release notes. +- Include uninstall/disable instructions and where repository-local activity files live. +- Explain that other agents are visible only after using the generic CLI/MCP adapter. +- Document lifecycle leases and the meaning of `idle`, `stale`, and `ready`. +- Document compact badge grammar, hidden/global `D` detail, and exact live/stale/handoff count semantics. +- Provide one documented install command per provider and a `doctor` flow that remains useful when only one provider is installed. +- Target under five minutes from built artifact to a diagnosed first visible session for either provider, excluding the product's unavoidable manual hook-trust review. +- Also measure the complete journey from install command through trust review to first visible session; target under ten minutes with no unexplained diagnostic gap. +- Keep schema examples free of real session IDs, paths, prompts, or task contents. + +--- + +## Success Metrics + +- Stackmap's ordinary main view shows every in-scope integrated local Codex, Claude, and generic participant on the correct visible branch/worktree; filtered, focused, archived, detached, or missing-branch participants remain discoverable from the always-visible hidden/unassigned cue and one-step `D` detail. +- Two providers claiming the same branch remain individually inspectable and visibly collide without blocking Git. +- Agent lifecycle/intent and Git-observed dirty/tip activity are never conflated. +- A new agent can query topology, worktrees, cleanliness, claims, intent, and attention through one shared contract. +- AE1-AE8 have automated evidence and the manual Codex/Claude checklist passes on real product surfaces. +- In a six-participant/four-worktree task test, the operator identifies branch ownership, a collision, and a waiting/unconfirmed session correctly within 15 seconds without switching applications. +- In ten representative new Codex/Claude coding tasks, at least nine report bounded intent automatically through bundled instructions; every miss is visibly `intent not reported` and never guessed. +- The ten-task sample includes main sessions and supported child/subagent/team tasks; inherited parent context is labelled separately from child-reported intent. +- Automatic reporting causes no recurring approval prompt after trusted installation, adds at most one initial orient/claim plus six meaningful transition writes, and has a documented presence-only opt-out. +- From a built local artifact, either provider reaches a diagnosed first visible session in under five minutes excluding manual trust review. +- The complete install-through-trust journey reaches a diagnosed first visible session in under ten minutes. +- A forced crash leaves recent live presentation within 15 minutes through the production activity path; stale retention/deletion is proven with the production-path injected clock. +- Without any plugin or with a broken registry, all existing Stackmap behavior remains usable. +- Privacy canaries prove forbidden automatically captured provider fields never reach disk, output, diagnostics, or the terminal; explicit bounded semantic reports remain clearly labelled intentional input. +- Existing format, strict lint, full tests, doctests, release build, passive/no-lock checks, and bounded-resource behavior pass. + +--- + +## Sources & References + +### Origin and repository + +- `docs/brainstorms/2026-07-28-agent-coordination-requirements.md` +- `docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md` +- `docs/features.md` +- `docs/invariants.md` +- `src/adapters/command.rs` +- `src/adapters/git.rs` +- `src/refresh/watcher.rs` +- `src/ui/layout.rs` + +### Codex + +- [Codex plugins](https://learn.chatgpt.com/docs/plugins) +- [Codex hooks](https://learn.chatgpt.com/docs/hooks) +- [Build plugins](https://developers.openai.com/plugins/build/plugins) +- [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp) + +### Claude Code + +- [Claude Code hooks](https://code.claude.com/docs/en/hooks) +- [Claude Code plugins](https://code.claude.com/docs/en/plugins) +- [Claude Code plugin reference](https://code.claude.com/docs/en/plugins-reference) +- [Claude Code MCP](https://code.claude.com/docs/en/mcp) +- [Claude Code plugin installation](https://code.claude.com/docs/en/discover-plugins) diff --git a/docs/plans/2026-07-28-002-feat-cross-project-agent-overview-plan.md b/docs/plans/2026-07-28-002-feat-cross-project-agent-overview-plan.md new file mode 100644 index 0000000..cefec77 --- /dev/null +++ b/docs/plans/2026-07-28-002-feat-cross-project-agent-overview-plan.md @@ -0,0 +1,170 @@ +--- +title: "feat: Add a cross-project agent overview" +type: feat +status: proposed-follow-up +date: 2026-07-28 +origin: docs/brainstorms/2026-07-28-agent-coordination-requirements.md +depends_on: docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md +--- + +# feat: Add a cross-project agent overview + +## Overview + +Add a bounded, user-local index that answers “what are all my agents working on across projects?” without scanning every directory, scraping agent processes, or replacing each repository's authoritative Stackmap activity registry. + +This is a separate follow-up. The repository-local provider-neutral contract in the main agent-coordination plan must stabilize first. + +--- + +## Success Contract + +The user can run one command or open one Stackmap overview and see every recently registered local repository with: + +- Repository display name and canonical identity. +- Current root/worktree paths that are still accessible. +- Live, idle, stale, and recently handed-off Codex, Claude, and generic participants. +- Claimed branch/worktree and bounded reported phase/intent. +- Collisions, waiting/blockers, stale repositories, and Git evidence requiring attention. +- Freshness and provenance for every value. + +The overview must not claim to discover an agent or repository that has never registered with Stackmap. + +--- + +## Scope + +### In scope + +- A user-level index containing bounded pointers/summaries for repository-local registries. +- Automatic registration when Stackmap status, TUI, hook ingestion, or MCP is used in a repository. +- Optional reconciliation from documented provider-wide listings such as `claude agents --json --all`, with repository resolution and privacy filtering. +- Explicit register, unregister, list, prune, and inspect commands. +- A cross-project CLI summary and an optional Stackmap launch mode. +- Duplicate repository/common-directory detection. +- Missing paths, moved clones, removable drives, and stale-record handling. +- Local privacy, concurrency, resource, and corruption guarantees. + +### Out of scope + +- Cross-machine, team, or cloud synchronization. +- Filesystem-wide repository crawling. +- Process-table, terminal, transcript, private-database, editor, or window scraping. +- Repository mutation or automatic checkout switching. +- Aggregating uninstalled/unintegrated agent runtimes. +- Replacing repository-local records with one global source of truth. + +--- + +## Architecture + +```mermaid +flowchart TB + R1["Repository A local registry"] --> IDX["User-local bounded index"] + R2["Repository B local registry"] --> IDX + R3["Repository C local registry"] --> IDX + IDX --> CLI["stackmap agent projects"] + IDX --> VIEW["Cross-project overview"] + CLI --> OPEN["Open selected repository in normal Stackmap view"] + VIEW --> OPEN +``` + +Repository-local state remains authoritative. The global index stores: + +- A versioned repository identity. +- Last-known canonical common-directory and worktree roots. +- Last registration/observation time. +- A bounded denormalized attention summary for fast listing. +- A pointer back to the local registry for fresh detail. + +The index never copies prompts, transcripts, command/tool data, file contents, or unbounded intent/history. + +--- + +## Key Decisions + +| Decision | Rationale | +|---|---| +| Registration, not discovery scan | Predictable cost and no surprise filesystem access | +| Documented provider listings are supplemental | They can recover background sessions but never replace repository-local hook/registry truth | +| Repository-local truth, global pointers | Linked worktrees reconcile correctly and one corrupt global entry cannot rewrite local ownership | +| Stable identity plus multiple paths | Clones, moved directories, symlinks, and removable drives need explicit ambiguity handling | +| Bounded denormalized summary | “All projects” stays fast even when some paths are offline | +| Explicit offline/stale states | Missing repositories are not silently deleted or shown as live | +| Open into the normal repo view | Cross-project UI is an index/attention surface, not a second full topology renderer | + +--- + +## Proposed Implementation Units + +- [ ] X1. **Freeze the repository-local registration contract** + +Depend on U2-U3 of the main plan. Define schema version, repository identity, canonical paths, attention summary, freshness, and writer provenance. Prove two linked worktrees register one repository while two clones remain distinguishable. + +- [ ] X2. **Build an atomic user-level repository index** + +Store independent per-repository files beneath the platform-appropriate Stackmap data directory. Use atomic replacement, strict size/count/string limits, an injected clock, and independent corruption handling. Avoid one shared mutable JSON file. + +- [ ] X3. **Register from existing Stackmap entry points** + +Refresh registration from TUI startup/reconciliation, `agent status`, hook ingestion, and MCP startup/calls. Registration is best-effort and must never block the repository-local operation. Explicit unregister/prune operations remain advisory and recoverable. + +- [ ] X4. **Reconcile documented provider-wide listings** + +Poll supported public provider surfaces such as `claude agents --json --all` with strict time/output/count bounds. Drop prompt-derived names and other sensitive fields, resolve each CWD through Git, merge by opaque provider/session identity, and prefer fresher repository-local hook evidence. Do not access private daemon, transcript, team, or job files. Providers without a documented listing remain registration-only. + +- [ ] X5. **Add cross-project CLI queries** + +Add `stackmap agent projects list`, `inspect`, `attention`, `prune`, and `open` semantics. Default output is human-readable; versioned JSON is available for agents. `inspect` re-reads the repository-local registry when accessible and clearly labels cached fallback. + +- [ ] X6. **Add an optional cross-project overview** + +Provide a compact project list ordered by attention then freshness. Show repository, live/idle/stale counts, provider mix, top bounded phase/intent, blockers/collisions, last seen, and offline state. Selecting a repository opens its normal topology view rather than duplicating branch rendering. + +- [ ] X7. **Harden migration, privacy, and lifecycle** + +Cover moved repositories, duplicate IDs, deleted clones, symlink changes, removable drives, permission loss, home-directory changes, schema upgrades, clock skew, excessive registrations, corrupt records, and uninstall cleanup. Apply the same privacy canaries as the repository-local feature. + +- [ ] X8. **Validate real multi-project workflows** + +Run real Codex and Claude sessions across at least three repositories and multiple worktrees. Confirm registration, attention ordering, collision summaries, offline transitions, pruning, and opening the correct repository. Measure startup/list latency with a deliberately large bounded index. + +--- + +## Acceptance Examples + +- Two linked worktrees for one repository produce one project row with two participant/worktree details. +- Two separate clones of the same remote do not merge solely because their remote URL matches. +- A Codex task in project A and Claude task in project B both appear with correct provider, phase, branch, and age. +- A generic agent appears only after registering through the provider-neutral contract. +- A disconnected removable drive shows an offline cached row rather than a live claim or silent deletion. +- One corrupt or oversized project entry does not hide other repositories. +- A stale global summary is visibly labelled and refreshes from local truth when the repository becomes accessible. +- A privacy canary present in provider hook input never appears in the user-level index or overview. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Global index becomes another authority | Store pointers/summaries only; local registry wins | +| Index grows without bound | Per-entry and total caps, expiry, explicit prune, deterministic eviction | +| Paths reveal sensitive project names | User-local permissions, bounded display, documented storage, optional unregister | +| Moved/duplicate repositories merge incorrectly | Stable identity plus canonical common-dir evidence and explicit ambiguity | +| Offline repositories slow startup | No broad scans; bounded parallel refresh; cached labelled fallback | +| Multiple processes corrupt the index | Independent per-repository atomic records | +| UI duplicates main topology complexity | Cross-project view is summary/navigation only | + +--- + +## Dependency and Handoff + +Do not implement this plan until the main plan freezes: + +- Repository identity and status schema. +- Activity/attention DTOs and lifecycle leases. +- Privacy allowlist. +- Generic CLI/MCP semantics. + +The main plan's U3 should leave a narrow registration callback boundary, but cross-project files, commands, and UI remain out of its delivery. diff --git a/docs/plans/2026-07-29-001-feat-stackmap-workflow-preflight-plan.md b/docs/plans/2026-07-29-001-feat-stackmap-workflow-preflight-plan.md new file mode 100644 index 0000000..f89d77b --- /dev/null +++ b/docs/plans/2026-07-29-001-feat-stackmap-workflow-preflight-plan.md @@ -0,0 +1,799 @@ +--- +title: "feat: Add Stackmap-aware workflow preflight and dirty-safe worktree routing" +type: feat +status: active +date: 2026-07-29 +origin: docs/brainstorms/2026-07-28-agent-coordination-requirements.md +--- + +# feat: Add Stackmap-aware workflow preflight and dirty-safe worktree routing + +## Overview + +Keep `stack-build` and `worktree-rules` as explicit, manually selected workflows +while making each one begin with the same read-only Stackmap preflight. Replace +`worktree-rules`' blanket refusal on an ordinarily dirty primary checkout with a +committed-parent routing policy: never target the occupied checkout for mutation, +create isolated worktrees from an exact committed ref when ancestry is clear, +and ask only when the requested work may depend on uncommitted changes or when +multiple parents are materially plausible. + +Also make live agent state persistently legible in Stackmap's ordinary branch +map. At responsive widths, a color-coded status field appears in the right-side +metadata rail immediately left of the commit timestamp; activity must no longer +replace or suppress the timestamp. The existing compact non-color activity badge +remains the narrow-width fallback. + +**Target workspaces:** + +- **Stackmap repository:** canonical shared preflight skill, TUI rendering, + package verification, and public documentation. +- **Codex skills workspace (local pilot):** reversible updates to + `worktree-rules/` and `stack-build/`, with paths below expressed relative to + that workspace root. This iteration records backups, installed-file hashes, + and rollback instructions; it does not claim these personal skills are + distributed to other machines. + +**Implementation baseline:** Stackmap-owned changes depend on +`codex/agent-coordination` at `16b04f9`, or a descendant containing that commit. +The currently active visual-feature checkout does not contain the integration +sources and must not be used as the implementation baseline by accident. + +--- + +## Problem Frame + +Stackmap can now show exact worktrees, cleanliness, active Codex/Claude/generic +participants, advisory claims, collisions, blockers, and handoffs. The manual +workflows do not yet consume that complete picture consistently: + +- `worktree-rules` treats any dirty primary checkout as a blocker even though + Git can safely create a new worktree from another committed ref—or from the + current branch's committed `HEAD`—without importing staged, unstaged, or + untracked changes. +- `worktree-rules` always pauses for parent confirmation, including cases where + explicit direction, project policy, or clear continuation intent identifies + exactly one safe parent. +- `stack-build` does not begin by checking whether another participant occupies + the current worktree or stack, even though it intentionally remains an + in-place, sequential, review-sized PR workflow. +- The main map currently reuses the timestamp area for agent disclosure and + renders most provider/detail text in one cyan style. The operator wants a + persistent, state-colored status beside—not instead of—the timestamp. + +The workflows should gain better observation and routing without becoming +implicit automation. Stackmap remains advisory and read-only; the manually +invoked workflow remains the only owner of branch, Graphite, and worktree +mutations. + +--- + +## Requirements Trace + +- R1. `stack-build` and `worktree-rules` remain explicit manual invocations. + Stackmap attention may recommend one, but never silently invokes either or + changes execution mode. +- R2. Both workflows begin with the canonical shared collector's bounded + `stackmap_status`, `stackmap_activity`, and `stackmap_attention` reads. If + Stackmap is missing, incompatible, or degraded, the existing manual + Git/Graphite discovery remains available as an explicitly labeled + non-Stackmap fallback; no second repository collector is introduced. +- R3. `worktree-rules` treats the source and primary checkouts as read-only + regardless of ordinary cleanliness. Dirty state does not block creation from + a committed ref, but its staged, unstaged, and untracked changes are excluded + and never copied, stashed, committed, patched, cleaned, or reset. +- R4. Parent routing is deterministic when intent is clear: explicit parent; + current committed `HEAD` for continuations or sibling variants; `staging` for + independent Factmachine work; `preview` only for explicit or established + preview/deployment lineage; repository-defined trunk elsewhere. +- R5. The workflow asks one focused question before mutation when relevant work + exists only in uncommitted state, multiple parents are materially plausible, + a target branch already has worktree ownership, or an overlap requires a + product decision. It does not infer dependency from filenames or contents: + explicit alternate-parent/explicit-exclusion requests may proceed, while a + dirty source plus current-HEAD continuation or variants asks whether to + exclude the dirty state or wait for a commit unless the user already answered. +- R6. Every worktree batch resolves one parent ref to one exact OID, publishes + an execution receipt, revalidates repository identity/ref/OID/ownership, + destination availability, and Graphite topology at each mutation boundary, + and verifies the primary checkout was preserved afterward. Concurrent + source/primary edits refresh the observation and force a new ancestry + decision only when they move the selected parent or reveal dependence on + uncommitted work. `unavailable`, conflicted, unborn, or active Git operation + states remain fail-closed. +- R7. `stack-build` remains an in-place Graphite workflow whose branches express + small, coherent, independently reviewable PRs. Preflight discloses overlap + and may recommend `worktree-rules`, but cannot invoke it automatically. +- R8. The Stackmap main map shows state-colored activity in a dedicated + responsive metadata field immediately left of the timestamp. The timestamp, + topology, committed diff, worktree, and PR evidence remain independently + visible at their supported widths. +- R9. Status color is not the only cue. Provider/count/state text or glyphs + remain meaningful under `NO_COLOR`, selection emphasis, narrow layouts, and + stale/degraded activity. +- R10. Workflow and UI changes preserve the origin requirements' authority and + privacy boundaries: Git proves repository state; agent reports describe + advisory intent; neither proves authorship or completion. + +This plan extends origin R2, R5-R7, R12-R17; it preserves the existing +provider-neutral registry, integration packaging, generic-adapter, and +real-surface requirements in origin R3-R4, R8-R11, and R18 +(see origin: `docs/brainstorms/2026-07-28-agent-coordination-requirements.md`). + +**Origin actors:** A1 (developer/operator), A2 (Codex agent), A3 (Claude agent), +A4 (generic agent), A5 (Stackmap TUI), A6 (agent consumer) + +**Origin flows:** F1 (agent arrives), F2 (agent changes intent), F3 (Git changes +independently), F4 (agent stops or hands off), F5 (operator installs and +verifies an integration) + +**Origin acceptance examples:** AE1 (worktree/cleanliness mapping), AE2 +(advisory collisions), AE3 (reported phase versus Git evidence), AE4 +(idle/stale/handoff lifecycle), AE5-AE6 (Codex/Claude/generic packaging +regression), AE7 (responsive disclosure), AE8 (privacy regression) + +--- + +## Scope Boundaries + +- No automatic invocation of `stack-build` or `worktree-rules`. +- No Stackmap MCP tool for branch, Graphite, worktree, checkout, restack, push, + or PR mutation. +- No automatic stash, commit, checkpoint, patch copy, clean, reset, or transfer + of uncommitted changes. +- No assumption that a Stackmap claim is a lock, parent relation, review verdict, + or mutation authorization. +- No file-level ownership or semantic-overlap inference. +- No automatic restacking of unrelated features or design variants. +- No change to `stack-build`'s manual, stream-of-consciousness creation of + review-sized Graphite branches. +- No cross-project or cross-host aggregation. + +### Deferred to Follow-Up Work + +- A source-controlled canonical home and general distribution mechanism for the + personal Codex workflow skills. U2-U3 are a reversible local pilot, not a + claim that those skills ship with Stackmap. +- Automatic cross-project scheduling based on the separately planned repository + index. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `integrations/shared/skills/stackmap-preflight/SKILL.md` already composes + status, activity, and attention, but currently publishes intent before an + orchestrated future worktree necessarily exists. +- `integrations/shared/skills/stackmap-coordinate/SKILL.md` correctly limits + claims to the workspace actually in use and keeps capability material private. +- `worktree-rules/SKILL.md` already preserves the primary checkout, detects + Graphite parentage, assigns one worktree per independent plan, revalidates + ancestry, and defers all implementation to fresh agents. Its blanket dirty + refusal and unconditional parent confirmation are the narrow policies being + superseded. +- `stack-build/SKILL.md` already asks about a parent only when the wrong choice + would materially matter, stays in the active checkout by default, and defers + location authority to `worktree-rules` when both are explicitly requested. +- `src/ui/tree/activity.rs` already derives state glyphs and a semantic palette: + active cyan; waiting/collision/unconfirmed yellow; blocked red; ready handoff + green; idle/stale/historical handoff dark gray. +- `src/ui/layout.rs` owns fixed right-side metadata geometry. A dedicated status + range belongs there so every row remains aligned and activity cannot move + connector cells. +- `src/ui/tree.rs` currently allows activity rendering to suppress timestamp and + PR fields. The new field must stop suppressing time while preserving existing + diff/worktree/PR priorities. +- `src/integration_tests/tui_rendering.rs` already covers 40/64/80/90/119/120/180 + widths, sidebar body widths, `NO_COLOR`, collisions, phases, handoffs, and + topology invariance. +- `scripts/verify-codex-plugin.sh` and + `scripts/verify-claude-plugin.sh` validate the canonical five-skill package. + +### Institutional Learnings + +- No relevant `docs/solutions/` entries exist; `memory.md`, `changelog.md`, and + prior plans are the durable record. +- `docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md` originally required + dirty-primary refusal. This plan supersedes only that blanket refusal while + preserving supported-schema checks, fallback, ownership detection, exact-OID + revalidation, and stale-precondition failure. +- Cleanliness belongs to a worktree, not globally to a branch or repository. + `unavailable` must never be interpreted as clean. +- The Git common directory legitimately changes when a worktree is created. + "Primary unchanged" therefore means branch, HEAD, index/staged state, tracked + dirty bytes, and untracked path/content—not an unchanged common directory. +- A stopped agent is idle, not complete. A truly dependent task may wait for a + committed handoff rather than guessing how to import dirty state. + +### External References + +- External research is intentionally omitted. Local Git characterization proved + that staged and untracked primary changes remain untouched while clean + worktrees are created from current committed `HEAD` and from another trunk. + The installed Stackmap contract and local skill definitions are authoritative + for this change. + +--- + +## Key Technical Decisions + +| Decision | Resolution | Rationale | +|---|---|---| +| Invocation | Keep both workflows manual | Selecting PR structure or parallel execution is user intent, not an inference from agent presence | +| Preflight | Automatic after manual invocation | Read-only context should be routine and low ceremony | +| Dirty checkout | Preserve and exclude ordinary dirty state | Git permits worktree creation from committed refs; the safety risk is importing or overwriting uncommitted work | +| Local skill delivery | Run U2-U3 as a reversible local pilot | This proves the workflow without expanding the plan into a general personal-skill distribution system | +| Parent routing | Resolve automatically only when one route is strongly supported | Correct ancestry matters more than avoiding one meaningful question | +| Claims | Advisory input only | Git worktree ownership and exact refs remain authoritative | +| Orchestrator claims | Do not claim a future implementation workspace | Child implementers claim only after their assigned worktree exists | +| Status placement | Dedicated metadata field left of timestamp | Activity should remain glanceable without erasing commit-age evidence | +| Status palette | Reuse existing activity-state mapping | Preserves learned semantics and avoids a second color vocabulary | + +### Parent-routing matrix + +| Intent/evidence | Selected parent | Behavior | +|---|---|---| +| Explicit parent | Named ref at exact OID | Proceed after policy and ownership validation | +| Continuation of current feature | Source checkout committed `HEAD` | Proceed when clean or dirty exclusion was already confirmed; otherwise use the dirty-source question below | +| Multiple design variants | One captured source `HEAD` OID | After the same cleanliness/exclusion gate, create sibling branches/worktrees from one OID | +| Independent Factmachine work | `staging` | Create sibling feature stacks | +| Explicit/established preview lineage | `preview` | Use only when deployment lineage—not frontend file type alone—supports it | +| Other independent repository work | Repository-defined trunk | Use the configured/default trunk | +| Relevant state only uncommitted | None yet | Ask whether to wait for a committed handoff or proceed without it | +| Dirty source plus current-HEAD continuation/variants | None until confirmed | Ask whether to exclude dirty state or wait for a commit, unless already answered | +| Multiple materially plausible parents | None yet | Ask one ancestry question before mutation | + +--- + +## Open Questions + +### Resolved During Planning + +- **Should Stackmap automatically invoke a workflow?** No. It provides automatic + preflight only after the user manually selects a workflow. +- **Does an ordinary dirty checkout block worktree creation?** No. It is + preserved read-only and excluded from any committed parent. +- **Does `unavailable` cleanliness also proceed?** No. Missing trustworthy + evidence, conflicts, active Git operations, and unborn branches remain + fail-closed. +- **Does touching frontend code imply `preview`?** No. Preview requires explicit + deployment intent, established lineage, or repository guidance. +- **Should status replace timestamp?** No. It receives a distinct responsive + field immediately to the timestamp's left. + +### Deferred to Implementation + +- Exact compact/full status field widths and abbreviations, after existing + renderer geometry tests establish the smallest non-wrapping allocation. +- Whether the status style helper remains in `src/ui/tree/activity.rs` or moves + to `src/ui/theme.rs`; preserve one semantic mapping either way. +- A source-controlled distribution home for the personal workflow skills after + the local pilot demonstrates the policy. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for +> review, not implementation specification. The implementing agent should treat +> it as context, not code to reproduce.* + +```mermaid +flowchart TB + Manual["Manual workflow invocation"] + Preflight["Stackmap read-only preflight"] + Fallback["Git/Graphite fallback"] + Route["Resolve execution mode and parent"] + Ask["Ask one material question"] + Receipt["Publish execution receipt"] + Revalidate["Revalidate exact Git/Graphite preconditions"] + Mutate["Workflow-owned branch/worktree mutations"] + Claim["Implementation participant claims actual workspace"] + + Manual --> Preflight + Preflight --> Route + Preflight --> Fallback + Fallback --> Route + Route --> Ask + Ask --> Route + Route --> Receipt + Receipt --> Revalidate + Revalidate --> Mutate + Mutate --> Claim +``` + +The main-view rendering path stays independent: + +```text +Activity snapshot + | + v +derive state/provider/count + | + +-- narrow: existing compact glyph in bounded metadata + | + +-- responsive: colored status field | timestamp | diff | WT | PR +``` + +--- + +## Implementation Units + +```mermaid +flowchart TB + U1["U1 Shared preflight contract"] + U2["U2 Dirty-safe worktree routing"] + U3["U3 Stack-build preflight"] + U4["U4 Main-row colored status"] + U5["U5 Packaging and acceptance"] + + U1 --> U2 + U1 --> U3 + U2 --> U5 + U3 --> U5 + U4 --> U5 +``` + +- [ ] U1. **Clarify the shared Stackmap preflight composition contract** + +**Goal:** Make the canonical preflight safe to compose with both manual +workflows and establish a repeatable local verification seam. + +**Requirements:** R1, R2, R10; origin R14-R17 + +**Dependencies:** `codex/agent-coordination` at `16b04f9` or descendant + +**Files:** +- Modify: `integrations/shared/skills/stackmap-preflight/SKILL.md` +- Create: `scripts/verify-agent-workflow-skills.sh` +- Test: `scripts/verify-agent-workflow-skills.sh` +- Verify unchanged contract: `scripts/verify-codex-plugin.sh` +- Verify unchanged contract: `scripts/verify-claude-plugin.sh` + +**Approach:** +- Keep status/activity/attention reads bounded, read-only, provider-neutral, and + fail-open for visibility. +- State that Stackmap may recommend a workflow but never silently invokes + `stack-build` or `worktree-rules`. +- Defer intent publication and claims until the actual execution workspace is + selected. An orchestrator planning future worktrees must not claim the source + checkout as implementation ownership. +- Add a verifier that validates the canonical skill package and the locally + installed workflow skills from an explicitly supplied skills root. Require a + YAML-capable validator runtime and fail visibly rather than silently skipping + full skill validation. + +**Patterns to follow:** +- `integrations/shared/skills/stackmap-coordinate/SKILL.md` +- `scripts/verify-codex-plugin.sh` +- `scripts/verify-claude-plugin.sh` + +**Test scenarios:** +- Happy path: a manually invoked workflow receives coherent status, activity, + and attention and proceeds to its own routing stage without publishing a + premature source-worktree claim. +- Fallback: missing, incompatible, timed-out, or degraded Stackmap labels + activity unavailable and returns to Git/Graphite discovery. +- Boundary: detecting attention outside a manual workflow recommends an + explicit workflow but performs no branch/worktree mutation. +- Privacy: receipts and validation output never include participant + capabilities, prompt text, or tool payloads. + +**Verification:** +- Both plugin package verifiers include one identical canonical preflight skill. +- The local workflow verifier distinguishes canonical Stackmap results from the + labeled non-Stackmap fallback and does not reimplement the shared collector. + +- [ ] U2. **Make `worktree-rules` dirty-safe and parent-aware** + +**Goal:** Permit isolated work from trustworthy committed refs while preserving +the source and primary checkouts and asking only about material ancestry or +uncommitted dependencies. + +**Requirements:** R1-R6, R10; origin R2, R5, R14-R17 + +**Dependencies:** U1 + +**Files (Codex skills workspace):** +- Modify: `worktree-rules/SKILL.md` +- Modify if activation copy changes: `worktree-rules/agents/openai.yaml` +- Test (Stackmap repository): `scripts/verify-agent-workflow-skills.sh` + +**Approach:** +- Distinguish source checkout, Git primary checkout, selected parent ref/OID, + and future implementation worktrees. +- Run orchestrator preflight before planning/dispatch, but do not claim future + work. Each fresh implementation agent revalidates and claims only after + entering its assigned worktree. +- Replace ordinary-dirty refusal with exclusion semantics. Preserve branch, + HEAD, index entries/staged state, tracked dirty bytes, and untracked + path/content from workflow commands; concurrent user edits may continue and + are observed rather than treated as workflow mutations. Never require the + shared common Git directory to remain unchanged. +- Use a deterministic dirty-dependency boundary rather than inspecting filenames + or contents. Explicit alternate-parent work or an explicit instruction to + exclude dirty state may proceed. Dirty current-HEAD continuations or variants + ask whether to exclude the uncommitted state or wait for a commit unless the + user already answered that exact question. +- Encode the parent-routing matrix and publish a receipt containing mode, + source/primary identities, parent ref/OID and reason, dirty exclusions, + sibling/dependent topology, and Stackmap/fallback attention. +- Revalidate immediately before every batch creation. Abort/reroute on parent + ref/OID movement, repository-identity drift, changed ownership, branch-name + race, occupied destination, or Graphite topology change. Treat concurrent + source/primary content or index edits as expected external activity: refresh + the receipt and proceed when the committed parent and dependency decision are + unchanged; ask again or stop when the edit moves the selected ancestry or + makes uncommitted content relevant. +- If the intended target branch is already checked out, ask one focused + question naming its owning path and offering only the meaningful choices: + resume in that workspace, choose a new branch, or stop. Make no mutation + before the answer. +- Keep unavailable cleanliness, active merge/rebase/cherry-pick/bisect, + conflicted index, unborn state, and unexpectedly untracked feature parents + fail-closed for the affected route. Resolve and inspect the per-worktree Git + directories for the source, primary, selected-parent owner when checked out, + and each target after creation. An operation in an unrelated linked worktree + does not block a demonstrably independent committed parent. +- Retain partially created worktrees on later failure and publish a blocker or + handoff rather than deleting potentially useful state. +- Make the orchestrator-task batch receipt idempotent: record a batch ID, exact + parent OID, intended branch/path pairs, and per-member + creation/verification state in the task transcript and blocker/handoff + summary. On retry within that task, adopt only a receipt-matching clean, + unmodified member; otherwise retain it and ask before selecting a replacement + branch/path. Cross-task durable batch storage remains out of scope. +- Before the local pilot edits either installed skill, create a recoverable + backup outside the live skill directory. Record before/after file hashes, + validation results, and exact rollback instructions in the execution receipt. + +**Patterns to follow:** +- Existing primary-preservation and fresh-implementation-agent boundaries in + `worktree-rules/SKILL.md` +- Prior exact-OID safety contract in + `docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md` + +**Test scenarios:** +- Dirty primary plus explicit `staging` creates a clean isolated worktree and + leaves branch, HEAD, index, tracked dirty bytes, and untracked contents exact. +- Dirty source plus current-HEAD variants creates distinct siblings from one + captured OID only after the user confirms exclusion (or a prior answer is + present), and states that all uncommitted changes were excluded. +- A task plausibly depending on dirty files asks whether to wait for a committed + handoff or proceed without them; no mutation occurs first. +- Two independent Factmachine features choose sibling stacks from one exact + `staging` OID. +- Explicit alternate trunk wins; generic frontend file scope alone does not + select `preview`. +- Parent ref movement before any member of a batch aborts rather than mixing + bases. +- Concurrent source edits that leave the exact selected parent and dependency + decision unchanged refresh the receipt and do not starve worktree creation. +- Source edits that move the selected parent or reveal reliance on uncommitted + work stop before the next mutation and require a new ancestry decision. +- Destination occupation, branch-name races, target ownership changes, and + Graphite topology drift are independently injected between planning and + creation; each stops before its affected mutation while preserving any + already-created worktrees for recovery. +- A parent branch checked out elsewhere can serve as a base for a new branch; + a target branch checked out elsewhere produces the focused resume/new + branch/stop question, names its owning path, and is not duplicated. +- Stackmap collision is disclosed but does not block an intentionally isolated + scope; Git ownership still wins when evidence disagrees. +- Unavailable cleanliness, conflict state, active Git operation, or unborn + parent blocks safely. +- Active operations and conflicted indexes in source, primary, selected-parent + owner, and created targets are detected through their per-worktree Git + directories. A blocked parent route does not prohibit an unrelated committed + parent whose relevant worktrees are healthy. +- Stackmap failure uses fallback discovery without weakening any exact-ref, + ownership, Graphite, or preservation check. +- A post-creation verification failure retains recoverable state and reports a + blocker/handoff. +- A failure after the first batch member records partial state; an exact clean + retry resumes it, while a drifted or newly claimed partial member is retained + and triggers one replacement/adoption question. + +**Verification:** +- Representative fresh-agent transcripts choose the expected parent or ask the + expected single question for every routing row. +- Disposable Git verification, owned by this unit, proves workflow + non-interference with the primary checkout, + clean child worktrees at exact selected OIDs, expected concurrent-edit + handling, per-worktree operation scoping, and idempotent partial-batch resume + without touching the live repository. +- The local pilot receipt proves the installed skills can be restored exactly + to their pre-pilot hashes. + +- [ ] U3. **Add recommendation-only Stackmap preflight to `stack-build`** + +**Goal:** Give the manual review-sized PR workflow live overlap awareness without +changing its in-place, sequential Graphite identity. + +**Requirements:** R1, R2, R7, R10; origin R5-R7, R14-R17 + +**Dependencies:** U1 + +**Files (Codex skills workspace):** +- Modify: `stack-build/SKILL.md` +- Modify if activation copy changes: `stack-build/agents/openai.yaml` +- Test (Stackmap repository): `scripts/verify-agent-workflow-skills.sh` + +**Approach:** +- Run one preflight in the active checkout before branch-unit planning and show + workspace path, branch/HEAD, cleanliness, Graphite base, participants, + claims, and attention. +- Preserve small, coherent, independently reviewable branches as the reason for + the skill; Stackmap state does not determine branch splitting. +- Continue in the active checkout when ownership is clear, including dirty + changes that clearly belong to the requested stack. +- When external overlap or ambiguous pre-existing changes matter, explain the + conflict and recommend explicit `worktree-rules`; never invoke it + automatically. +- When already inside a worktree supplied by `worktree-rules`, keep that + workflow authoritative for location, primary preservation, and cleanup. +- Refresh the advisory claim as the checked-out stack branch changes, without + treating the claim as Graphite ancestry. + +**Patterns to follow:** +- Existing base-selection and branch-ownership checks in `stack-build/SKILL.md` +- `integrations/shared/skills/stackmap-preflight/SKILL.md` + +**Test scenarios:** +- Clear manual invocation prints an in-place receipt and creates no worktree. +- A participant collision produces a focused continue-or-isolate choice and + does not invoke `worktree-rules`. +- Dirty changes clearly owned by the requested stack may proceed; unrelated or + ambiguous changes pause before a branch claims them. +- Invocation inside an assigned worktree preserves `worktree-rules` authority. +- Stackmap unavailable falls back to existing repository/Graphite inspection. +- Branch units remain driven by coherent PR review boundaries, not lifecycle + phases, claims, or participant counts. + +**Verification:** +- Fresh-agent transcripts preserve manual activation and produce the expected + in-place receipt, recommendation, or focused question. +- Existing Graphite ancestry and cumulative verification requirements remain + present and unchanged. + +- [ ] U4. **Add a persistent color-coded activity status beside the timestamp** + +**Goal:** Make agent state glanceable from the ordinary branch map without +opening detail and without sacrificing commit-age evidence. + +**Requirements:** R8-R10; origin R6, R7, R12, R13, AE3, AE7 + +**Dependencies:** `codex/agent-coordination` at `16b04f9` or descendant; may +proceed independently of U1-U3 + +**Files:** +- Modify: `src/ui/layout.rs` +- Modify: `src/ui/tree/activity.rs` +- Modify: `src/ui/tree.rs` +- Modify if semantic styling is centralized: `src/ui/theme.rs` +- Test: `src/integration_tests/tui_rendering.rs` +- Test: `src/ui/tree/activity.rs` + +**Approach:** +- Add an optional fixed activity-status range in `RenderGeometry` immediately + left of the existing time range at widths that can support both. Reserve at + least one blank cell between status and timestamp and preserve the existing + minimum branch-name and topology budgets. +- Keep branch-row columns globally aligned: rows without activity leave the + status range blank rather than reclaiming it and shifting names or metadata. +- Render a bounded provider/count plus phase or attention token using the + existing state palette. Prefer attention states such as collision, blocked, + waiting, ready, and stale over a lower-urgency reported phase. +- Contract by discrete semantic tiers rather than arbitrary partial-provider + truncation: full provider set/count plus state text; deterministic aggregate + provider marker/count plus state; then state glyph/count only. Within a tier, + retain the state glyph first, then provider/count, then attention/phase text. +- Stop setting `suppress_time` for active rows. Timestamp continues to render in + its existing range; diff, worktree, and PR evidence retain explicit responsive + priority. +- Retain the compact badge for widths that cannot afford a dedicated status + range. Keep provider, count, phase, and state discoverable through text/glyphs + when color is disabled or selection styling overrides foreground color. +- Treat state glyph/text as the authoritative cue when `NO_COLOR`, selection, + or current-row emphasis suppresses semantic foreground color. Route the + palette through the existing no-color-aware theme seam; selected rows use the + established high-contrast foreground, and dim states must not combine a + low-contrast foreground with `DIM`. +- Preserve the wide intent disclosure and `d` detail sidebar/modal as deeper + evidence. Opening detail preserves selection and branch identity, retains at + least the compact inline cue when geometry permits, and always shows full + state/provider/count evidence in the sidebar or modal when the dedicated + field contracts. + +**Patterns to follow:** +- `badge_style`, `state_glyph`, `phase_or_attention`, and provider aggregation + in `src/ui/tree/activity.rs` +- Fixed metadata geometry in `src/ui/layout.rs` +- Selected/current/no-color emphasis in `src/ui/tree.rs` and `src/ui/theme.rs` + +**Test scenarios:** +- Covers AE3 / AE7. Active, testing, reviewing, waiting, blocked, live + collision, idle, stale, + ready handoff, and historical handoff render the expected token and semantic + style. +- An activity row retains the exact same timestamp as its no-activity baseline. +- The status range ends before the timestamp and never overlaps diff, worktree, + PR, branch-name, or topology cells. +- Every supported dedicated-field layout retains a one-cell + status-to-timestamp gutter and the existing minimum branch-name/topology + budgets; contraction never cuts through a provider token. +- Rows with no activity remain aligned with active rows and show no invented + status. +- Widths 40, 56, 64, 72, 80, 90, 119, 120, and 180 remain one line with stable + topology; disclosure contracts predictably when the dedicated field is absent. +- Opening the wide detail sidebar recomputes body geometry without moving + connectors or losing the timestamp; selection remains stable and the selected + branch exposes equivalent or fuller status evidence after contraction. +- `NO_COLOR` retains state/provider/count text or glyphs and every activity + status cell uses `Color::Reset`. +- Normal, selected, current, context-dimmed, and `NO_COLOR` modes have + style-level assertions for every semantic state; selected/current rows retain + established high-contrast foregrounds and non-color state identity. +- Codex-only, Claude-only, generic-only, and mixed-provider collisions use + bounded deterministic formats at every contraction tier. + +**Verification:** +- Agent status is visible on the main page immediately left of timestamp where + supported, while timestamp and all Git evidence remain independently visible. +- Existing rendering, geometry, and responsiveness suites remain green. + +- [ ] U5. **Package, document, and prove the complete workflow** + +**Goal:** Complete two independently landable delivery gates—manual workflow +preflight/routing and main-row rendering—then run one combined smoke test. +Stackmap's canonical preflight ships to installed Codex/Claude plugins; the +personal `stack-build` and `worktree-rules` changes remain a reversible local +pilot with durable acceptance evidence. + +**Requirements:** R1-R10; origin R14-R18 + +**Dependencies:** U2, U3, U4 + +**Files:** +- Modify: `README.md` +- Modify: `docs/features.md` +- Modify: `integrations/codex/README.md` +- Modify: `integrations/claude/README.md` +- Modify: `memory.md` +- Modify: `changelog.md` +- Verify/package: `scripts/verify-codex-plugin.sh` +- Verify/package: `scripts/verify-claude-plugin.sh` +- Verify: `scripts/verify-agent-workflow-skills.sh` + +**Approach:** +- Gate A (U1-U3) documents and validates manual preflight/routing independent of + renderer completion. Gate B (U4) documents and validates status rendering + independent of the local workflow pilot. Either gate may land while the other + is repaired; one final smoke test proves their combined experience. +- Document the manual invocation boundary, automatic preflight, parent-routing + matrix, dirty exclusion semantics, execution receipts, and wait/handoff path. +- Document the main-row color/status placement and non-color semantics. +- Validate the canonical skill package and both local workflow skills with a + validator runtime that supports YAML; do not silently accept a skipped + validator. +- Repackage/reinstall Codex and Claude integrations so fresh tasks receive the + updated canonical preflight skill and renderer-compatible Stackmap binary. + Do not imply that Claude receives the personal Codex workflow skills. +- Install U2-U3 only as the reversible local pilot: retain pre-edit backups, + record exact before/after hashes and validator output, and prove the documented + rollback restores the original hashes. +- Exercise one fresh Codex task and one fresh Claude task in separate disposable + worktrees, including a visible collision/attention state and colored main-row + status. +- Exercise the local manual skills with clear current-HEAD continuation, + explicit alternate trunk, independent `staging`, ordinary dirty exclusion, + ambiguous dirty dependence, and Stackmap fallback transcripts. + +**Patterns to follow:** +- Existing plugin packaging and exact-binary verification scripts +- Changed-surface origin examples AE1-AE4 and AE7; run existing privacy and + lifecycle suites as regression checks, and retain AE5-AE6 package plus AE8 + privacy coverage without re-certifying every original acceptance surface + +**Test scenarios:** +- Covers AE1. End-to-end: dirty occupied primary plus an independent manually invoked + `worktree-rules` task creates a clean `staging`-based worktree, preserves the + primary, registers the implementation participant, and displays its colored + status beside timestamp. +- Variants: two manual worktree variants use one exact current-HEAD OID, appear + as sibling branches, and do not import dirty source changes. +- Manual boundary: an ordinary task that notices Stackmap attention recommends + but does not invoke either workflow. +- Stack-build: manual invocation produces a review-sized Graphite plan and + in-place receipt without creating a worktree. +- Failure: unsupported Stackmap schema labels visibility unavailable and falls + back without weakening Git/Graphite validation. +- Covers AE8. Privacy: no receipt, package output, registry record, or rendering contains + capabilities, prompts, transcripts, tool output, or source contents. + +**Verification:** +- Gate A passes plugin package checks, workflow validation, disposable Git + characterization, and fresh-task workflow acceptance without depending on + U4. +- Gate B passes formatting, strict lint, renderer/unit/integration suites, + release build, and visual operator acceptance without depending on U2-U3. +- The combined smoke test shows a newly created participant in its isolated + worktree and the matching colored main-row state beside an unchanged + timestamp. +- Installed Codex and Claude packages expose the updated canonical preflight; + locally piloted `stack-build` and `worktree-rules` retain explicit manual + activation and a tested rollback receipt. + +--- + +## System-Wide Impact + +- **Interaction graph:** Manual skill invocation starts Stackmap preflight; + preflight informs workflow routing; `worktree-rules` owns worktree/Graphite + mutation; fresh implementers claim actual workspaces; activity snapshots feed + the main-row status renderer and detail view. +- **Error propagation:** Stackmap visibility failure degrades to existing + discovery; unavailable Git identity/cleanliness, active operations, parent + drift, ownership conflicts, and Graphite inconsistency remain blocking. +- **State lifecycle risks:** The source checkout may change during planning; + parent refs may move during a batch; branch names and paths may race; partially + created worktrees must remain recoverable. +- **API surface parity:** Codex and Claude packages share one canonical preflight + skill. Generic MCP tools remain provider-neutral and read-only except for + advisory coordination records. +- **Integration coverage:** Disposable real-Git worktrees must prove exclusion + and primary preservation; renderer tests must prove status/timestamp + coexistence; fresh installed tasks must prove lifecycle visibility. +- **Unchanged invariants:** Claims do not lock work; Stackmap does not mutate + Git; `stack-build` remains in-place and review-unit driven; + `worktree-rules` never mutates the primary checkout; Graphite remains + authoritative for stack parentage and restacking. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Agent interprets dirty exclusion as permission to lose work | Prohibit workflow commands from targeting the occupied checkout, disclose exclusion, and prohibit automatic stash/commit/copy/clean | +| Agent chooses the wrong parent autonomously | Use deterministic routing and ask when multiple parents materially fit | +| A batch creates variants from different moving bases | Resolve one exact OID and revalidate the parent ref before each creation | +| A partial batch is retried without its original task receipt | Retain discovered branches/worktrees, refuse automatic adoption, and ask before choosing replacement names; cross-task receipt storage is follow-up work | +| Stackmap claim is mistaken for Git ownership | Keep claims advisory and revalidate `git worktree` ownership | +| Orchestrator claims a workspace it will not edit | Delay claims until fresh implementers enter created worktrees | +| Status field crowds existing metadata | Allocate fixed responsive geometry and retain the narrow compact badge | +| Status color conflicts with selection or `NO_COLOR` | Preserve state glyph/text and test selected/current/no-color paths | +| Current checkout lacks integration sources | Base implementation on `16b04f9` or descendant | +| Personal skills are not source-controlled here | Validate explicit installed paths and defer distribution/source strategy | +| Default validator lacks PyYAML | Use a known compatible runtime and fail visibly if full skill validation cannot run | + +--- + +## Documentation / Operational Notes + +- Update the tutorial language so users understand that manual workflow + selection remains intentional while preflight is automatic within it. +- Include example receipts for current-HEAD variants, independent `staging` + work, explicit `preview`, and dirty-dependency waiting. +- Explain that ordinary dirty state is compatible with worktree creation but is + never inherited without a commit. +- Document the status palette and the distinction between colored agent state, + agent-reported intent, and Git-observed timestamp/diff/cleanliness. +- Fresh tasks are required after plugin updates; already-open tasks need not + hot-load revised skills or hooks. + +--- + +## Sources & References + +- **Origin document:** `docs/brainstorms/2026-07-28-agent-coordination-requirements.md` +- Prior status/workflow plan: `docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md` +- Agent-coordination implementation plan: `docs/plans/2026-07-28-001-feat-agent-coordination-integrations-plan.md` +- Stackmap workflow skills: `integrations/shared/skills/stackmap-preflight/SKILL.md`, + `integrations/shared/skills/stackmap-coordinate/SKILL.md` +- Stackmap UI: `src/ui/layout.rs`, `src/ui/tree/activity.rs`, + `src/ui/tree.rs`, `src/integration_tests/tui_rendering.rs` +- Codex skills workspace: `worktree-rules/SKILL.md`, `stack-build/SKILL.md` +- Project record: `memory.md`, `changelog.md` diff --git a/memory.md b/memory.md index 5155e73..6b650e3 100644 --- a/memory.md +++ b/memory.md @@ -18,7 +18,7 @@ - Recent is the startup order. `T` selects Recent/Alphabetical/Graphite, `+/-/0` controls global lane pitch, `C` opens the color picker, and `g/G` are the terminal-portable section-edge fallback for Option+Arrow. - `x` is reversible archive/restore and moves focus to the nearest visible row above, `a` toggles Archive view, and `v` previews an inclusive contiguous range committed with Enter. Archive state persists by branch name in the repository-local config; structural refresh automatically unarchives names that become current or configured trunks. Archive view renders required unarchived ancestry as dim, nonselectable stack context. - `n` edits a persistent repository-local name for the selected stable stack ID. The white label is a nonselectable semantic row immediately above that stack's head; Enter saves, empty Enter clears, and Esc cancels. Trunks are intentionally unnamed. -- Archive remote-ref/upstream evidence is local-only and no-fetch. Containment work runs only for a bounded visible Archive working set, uses one active/latest-pending coordinator, and reports unavailable/loading states without claiming `local only`. +- Remote-ref/upstream evidence is local-only and no-fetch in both Active and Archive views. Containment work runs only for the bounded visible working set through one active/latest-pending coordinator. - Checkout and deletion share one mutation state. Post-mutation refreshes carry causal request epochs, so queued pre-mutation snapshots cannot release the mutation slot. - Deletion is exact, local-only, confirmed with uppercase `X` then `y/n`, and non-force. Lowercase `x` never deletes. Git-only deletion is merged-to-HEAD and expected-OID atomic. Graphite deletion is tracked-leaf-only, validates raw children, uses a cached allowlisted CLI contract, re-reads Git plus raw metadata after every invocation, and blocks inconsistent results. - Config persistence drains active and latest coalesced writes after terminal restoration during quit. The drain is capped at five seconds, retries one failed final write, and exits nonzero if persistence still fails. @@ -26,6 +26,8 @@ - Rendering uses direct snapshot-row, projected-row, lane-count, and lane-span indexes; viewport work does not scan the complete branch or stack set. - Connector junction cells retain the parent/left rail color; the horizontal segment and child corner switch to the child stack color. The selected row fills edge-to-edge with its stack/trunk identity color while the checked-out branch retains its subtler tint when not selected. - Enhanced terminals receive the complete Crossterm keyboard protocol flags. Shift+Arrow and Command+Arrow both map to stack jumps when the terminal reports their modifier; Stackmap decodes standard `ESC [1;2A/B` Shift sequences, and `J/K` remains the portable fallback when a terminal intercepts or erases modifiers. +- Downward stack navigation treats the configured trunk as the final destination after the lowest real stack, so Shift+Down and `J` can land on the trunk even though it is not itself a stack. +- In focused views the trunk remains a sticky bottom row; when Shift+Down or `J` lands on it from the lowest stack, the scrollable stack rows also align to their bottom-most viewport position. - The crate and lockfile are pinned to Rust 1.88.0. Formatting, strict offline Clippy, 175 all-target/all-feature tests, the offline release build, focused Tier-2 review, real Git/Graphite integration, and PTY smoke tests pass on that toolchain. - The open-source alpha identity is `0.1.0-alpha.1`. Cargo publication is disabled; GitHub source and two-architecture macOS prerelease assets are the intended distribution paths. - The executable exposes a narrow documented runtime facade. White-box integration coverage is crate-internal and benchmarks use a feature-gated benchmark facade rather than public application internals. @@ -38,13 +40,42 @@ - Release benchmarks measured about 0.108 ms per 500-branch projection, 1.10 ms per 5,000-branch projection, and 0.94 ms per projection of one 5,000-branch deep stack. A 5,000-level/10,000-branch deep comb emits iteratively without call-stack recursion; broad attach-parent lookup is indexed. - Public development lives at `https://github.com/fieldsofland/stackmap` on protected `main`. Native macOS ARM64/Intel CI, current-stable compatibility, and dependency policy pass at commit `0f01e4346448c700c0474061734005f08e767405`. - `stackmap 0.1.0-alpha.1` is installed at `/Users/matt/.cargo/bin/stackmap`, resolves on `PATH`, and passes startup/quit smoke testing in a disposable Git repository. +- Visual feature sections are repository-local presentation ranges anchored by branch name. `i` toggles a boundary; successive boundaries cumulatively indent only branch-name/label text while real circles, rails, connectors, and Git/Graphite state remain unchanged. +- Visual sections persist an atomic optional name plus concrete color. Their effective colors differ from adjacent base/section ranges; `c`/`C` act on an exact boundary or selected section label, while ordinary member branches retain stack-color behavior. +- Stack and visual-section labels are selectable rows. `n` creates only a missing label, Enter edits an existing selected label inline, empty Enter removes only the label, and Escape cancels. Name editing owns raw printable input (including `j/k/g/G/J/K`), repeats, and Backspace before normal bindings; Ctrl-C is inert until editing ends. +- Inline name editing has a Unicode-safe character cursor: Left/Right and Home/End move it, typing inserts at it, Backspace removes before it, and Delete removes after it. +- Named stack rows show the total number of real branches owned by that topology group, excluding the trunk and separate child/side stacks and remaining stable under filtering. +- The wide branch-detail sidebar is session-only, hidden by default, and toggled with `d` at 120 columns or wider. +- Selected rows use their full identity-color fill with black foreground content across branch name, time, diff, worktree, and PR metadata. The unselected checked-out row uses a 40%-strength blend of its identity color over black. +- Metadata geometry reserves a blank gutter between relative time and diff columns and a blank trailing cell after the PR column so dense wide rows remain readable. +- Compact diff counts below ten thousand retain one decimal digit (`4.3K` instead of `4K`); larger compact values remain integer thousands to preserve fixed-width alignment. +- Branch checkout requires two consecutive Enter presses on the same branch. The first opens a centered confirmation popup naming the target and showing `Enter switch · Esc cancel`; Enter confirms, while Escape or navigation cancels before Git runs. Label Enter still edits immediately. +- Consecutive structural snapshots compare existing branch tip OIDs. Selection follows the most recently committed changed branch that remains selectable in the current view; enrichment-only updates, initial load, deletions, and hidden changes do not move the cursor. +- Selected stack and visual-section label rows retain their stack/section identity color as the highlight and use white foreground content, including aggregate diff text. +- Focused sections shorter than the viewport bottom-align their complete visible content immediately above the pinned trunk/bottom row; long focused sections retain the existing scrolling and continuation cues. +- The current test build, including checkout confirmation, changed-branch cursor following, identity-colored label selection, focused-view bottom alignment, precise compact diffs, sticky-trunk bottom navigation, cursor-aware name editing, and named-stack branch counts, is installed at `/Users/matt/.cargo/bin/stackmap` with SHA-256 `9ca3c2d70475360caaf553d019cbb349bb523ca345a8c9fa2a7762ad38e51dc7`. It exactly matches `target/release/stackmap`. +- The agent integration plan is `docs/plans/2026-07-21-002-feat-agent-status-cli-plan.md`: v1 is a versioned read-only `stackmap agent status` contract with complete per-worktree cleanliness and integrations for `fm-mobile-review`/`worktree-rules`; JSONL watch, annotations, MCP, and mutations are deferred until usage proves demand. +- The reviewed stack-summary plan is `docs/plans/2026-07-21-003-feat-stack-summary-hierarchy-plan.md`: named stack titles show a true displayed-group base-to-tip net diff, stack titles get a dedicated spacer, section titles stay adjacent to owned branches, and metadata gains a diff-to-worktree gutter. Aggregate scheduling must pass a 500/5,000-stack responsiveness characterization or fall back to named/on-demand targets. +- Named stack summaries are now generation-scoped `DiffState` values keyed by real displayed topology-group ID. They compare the bottom branch's validated parent OID directly to the group's primary head OID; side stacks therefore exclude parent/sibling work and intermediate reversions produce a true net result. +- Diff enrichment runs branch/shared OID-pair tasks first and publishes that immutable partial snapshot before aggregate-only tasks. Aggregate work remains deduplicated, cache-bounded, cancellation-aware, capped at four workers, and characterized at 500/5,000 groups. +- Named stack projection is `StackLabel -> dedicated nonselectable Spacer -> section label or branch`; section labels remain adjacent to their first visible owned branch. Metadata reserves time-to-diff, diff-to-worktree, and PR-to-edge gutters. - Passive Git monitoring runs with optional locks disabled, so inventory/status/diff commands cannot refresh or lock the index. Explicit checkout and deletion retain normal Git locking and use a separate 30-second mutation deadline. - Repository notifications are path-filtered and quiet-period debounced; transient locks, object-store writes, logs, and temporary files do not trigger structural refreshes. Five-minute reconciliation is a dropped-event safety net rather than the primary monitor. - Normal quit, terminal-close signals, and recoverable error exits stop the watcher, terminate registered subprocess groups, and join refresh/upstream workers. Timed-out children receive TERM before KILL. - A release-build smoke test against the FactMachine monorepo settled at 0.0% CPU, created no `index.lock`, and left no Stackmap or Git child after `q`. +- User-authored stack and visual-section titles render in white in every row state; branch names retain their stack/section identity colors and selected-row contrast treatment. +- GitHub enrichment requests all pull-request states and renders `Merged`, `Closed`, or `Approved` in the PR column, falling back to the PR number for other open requests. Matching remains branch-ID plus tip-OID guarded. +- The main-page remote column renders `✓ pushed`, `↑n ahead`, `↓n behind`, `↕n/n div`, `× gone`, `○ no remote`, or `? remote`. No configured/contained remote is explicitly distinct from a configured upstream whose local remote-tracking ref is gone. +- The current verified `stackmap 0.1.0-alpha.1` release is installed at `/Users/matt/.cargo/bin/stackmap` with SHA-256 `125d5706162938ad224e1bbe9fe94c134c5fb07026469374758e237850b50e02`. +- The reviewed workflow-preflight plan is `docs/plans/2026-07-29-001-feat-stackmap-workflow-preflight-plan.md`: manual `stack-build` and `worktree-rules` gain shared read-only preflight; explicit alternate committed parents can proceed beside a dirty checkout, while dirty current-HEAD continuations/variants ask whether to exclude or wait. The main map gains a color-coded, non-color-accessible activity field immediately left of timestamp. ## Next steps +- Treat cross-host agent activity as deferred roadmap work: first prove the read-only agent status contract, then evaluate a bounded local registry with heartbeat/TTL, Claude hooks, a cooperative Codex skill, optional cmux enrichment, and clearly labeled passive inference. Do not scrape terminals or private app databases. +- Evaluate assisted visual reorganization only as a reviewable proposal workflow; keep Graphite authoritative for branch movement and restacking. +- Implement the agent status plan only after reviewing the schema-v1 fixture matrix and keeping worktree cleanliness fail-closed at mutation boundaries. +- Execute the workflow-preflight plan from `codex/agent-coordination` commit `16b04f9` or a descendant; treat the personal workflow-skill edits as a reversible local pilot with hashes and rollback evidence. +- Exercise visual feature sections and named-stack summaries in representative real stacks, especially multiple adjacent sections, real side-stack forks, filtering/archive views, and narrow terminals; adjust interaction feel before opening a PR. - Exercise the public alpha in representative repositories and record manual Terminal.app evidence for both macOS architectures. - Cut `v0.1.0-alpha.1` only after reviewing the protected prerelease environment and the release checklist in `docs/releasing.md`. - Resolve alpha feedback and make the Developer ID signing/notarization decision before a stable `0.1.0` release. diff --git a/src/adapters/github.rs b/src/adapters/github.rs index 7332723..72c32cb 100644 --- a/src/adapters/github.rs +++ b/src/adapters/github.rs @@ -6,7 +6,7 @@ use std::time::Duration; use serde::Deserialize; use super::command::{CommandError, CommandOutput, run_bounded}; -use crate::model::{BranchId, PullRequest}; +use crate::model::{BranchId, PullRequest, PullRequestStatus}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -16,6 +16,8 @@ struct GhPullRequest { url: String, head_ref_name: String, head_ref_oid: Option, + state: String, + review_decision: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -73,11 +75,11 @@ pub fn fetch(cwd: &Path) -> Result, GitHubError> { "pr", "list", "--state", - "open", + "all", "--limit", "1000", "--json", - "number,title,url,headRefName,headRefOid", + "number,title,url,headRefName,headRefOid,state,reviewDecision", ]; let output = run_bounded( OsStr::new("gh"), @@ -115,6 +117,14 @@ pub fn parse_json(bytes: &[u8]) -> Result, GitHubError> { number: value.number, title: Arc::from(value.title), url: Arc::from(value.url), + status: match value.state.as_str() { + "MERGED" => PullRequestStatus::Merged, + "CLOSED" => PullRequestStatus::Closed, + _ if value.review_decision.as_deref() == Some("APPROVED") => { + PullRequestStatus::Approved + } + _ => PullRequestStatus::Open, + }, }, }) }) diff --git a/src/app.rs b/src/app.rs index 9a517db..d724f38 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,12 @@ use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::adapters::git::{DeleteOutcome, DeleteRequest}; use crate::adapters::github::{GitHubError, PrMatch}; -use crate::config::{ArchiveMutation, Config, ConfigMutation, MAX_STACK_NAME_CHARS}; +use crate::config::{ArchiveMutation, Config, ConfigMutation, MAX_STACK_NAME_CHARS, VisualSection}; use crate::events::{Input, Key, KeyPhase}; use crate::model::topology::{ ArchiveMode, Emphasis, OrderMode, ProjectionOptions, ProjectionScope, TopologyIndex, @@ -40,6 +41,24 @@ const ORDER_OPTIONS: &[OrderMode] = &[ ]; type UpstreamWorkingSet = (u64, Vec<(BranchId, Arc)>); +fn changed_branch_candidates( + current: &RepositorySnapshot, + next: &RepositorySnapshot, +) -> Vec { + let mut changed = next + .branches + .iter() + .filter(|branch| { + current + .branch(&branch.id) + .is_some_and(|previous| previous.oid != branch.oid) + }) + .map(|branch| (branch.committed_at, branch.id.clone())) + .collect::>(); + changed.sort_by(|left, right| right.cmp(left)); + changed.into_iter().map(|(_, branch)| branch).collect() +} + mod archive; mod mutation; mod overlays; @@ -49,9 +68,9 @@ use archive::archive_range_slice; use mutation::{confirmation_is_valid, deletion_refusal, stale_deletion_refusal}; use overlays::order_option_index; pub use state::{ - Action, ArchiveRange, ColorPicker, ConfigWriteRequest, DeleteConfirmation, DeletionResult, - GitHubState, LanePitch, MutationState, OrderPicker, Overlay, ReconciliationOperation, - StackNameEditor, ViewScope, + Action, ArchiveRange, ColorPicker, ConfigTarget, ConfigWriteRequest, DeleteConfirmation, + DeletionResult, GitHubState, LanePitch, MutationState, OrderPicker, Overlay, + ReconciliationOperation, StackNameEditor, ViewScope, }; use state::{AllViewState, TransientNotice}; @@ -60,6 +79,7 @@ pub struct App { pub projection: TopologyProjection, topology: Option, pub selected: Option, + pub selected_label: Option, pub scroll: usize, pub viewport_height: usize, pub filter: String, @@ -75,6 +95,7 @@ pub struct App { pub lane_pitch: LanePitch, pub scope: ViewScope, pub separators: bool, + pub detail_sidebar: bool, pub archive_mode: ArchiveMode, restore_selection: Option, all_view_state: Option, @@ -86,6 +107,7 @@ pub struct App { pending_colors: HashMap>)>, pending_archives: HashMap, pending_stack_names: HashMap>)>, + pending_visual_sections: HashMap)>, queued_config_write: Option, active_view_state: Option, notice: Option, @@ -104,6 +126,7 @@ impl Default for App { projection: TopologyProjection::default(), topology: None, selected: None, + selected_label: None, scroll: 0, viewport_height: 12, filter: String::new(), @@ -119,6 +142,7 @@ impl Default for App { lane_pitch: LanePitch::Auto, scope: ViewScope::All, separators: true, + detail_sidebar: false, archive_mode: ArchiveMode::Active, restore_selection: None, all_view_state: None, @@ -130,6 +154,7 @@ impl Default for App { pending_colors: HashMap::new(), pending_archives: HashMap::new(), pending_stack_names: HashMap::new(), + pending_visual_sections: HashMap::new(), queued_config_write: None, active_view_state: None, notice: None, @@ -194,6 +219,14 @@ impl App { { return; } + let changed_branches = if structural { + self.snapshot + .as_deref() + .map(|current| changed_branch_candidates(current, &snapshot)) + .unwrap_or_default() + } else { + Vec::new() + }; if let Some(current) = &self.snapshot { let current_prs: std::collections::HashMap<_, _> = current .branches @@ -263,6 +296,15 @@ impl App { ))); } } + for (anchor, (_, section)) in &self.pending_visual_sections { + if let Err(error) = + config.set_visual_section_in_memory(anchor, section.clone()) + { + self.message = Some(Arc::from(format!( + "pending visual section ignored after validation changed: {error}" + ))); + } + } self.config = config; } Err(error) => { @@ -276,6 +318,9 @@ impl App { if projection_changed { self.topology = Some(TopologyIndex::build(&snapshot)); } + if structural { + self.prune_invalid_visual_sections(); + } let protected_deletion_target = structural .then(|| self.deletion_target_in_flight()) .flatten(); @@ -338,9 +383,20 @@ impl App { if projection_changed { self.reproject(); } - if let Some(selection_after) = deletion_selection_after { + let recovered_deleted_selection = if let Some(selection_after) = deletion_selection_after { self.selected = selection_after .filter(|branch| self.projection.branch_to_selectable.contains_key(branch)); + true + } else { + false + }; + if !recovered_deleted_selection + && let Some(changed) = changed_branches + .into_iter() + .find(|branch| self.projection.branch_to_selectable.contains_key(branch)) + { + self.selected = Some(changed); + self.selected_label = None; } self.reconcile_selection(); self.revalidate_overlay(); @@ -348,15 +404,12 @@ impl App { self.startup_current_pending = false; self.apply_current_startup(); } - if structural && matches!(self.archive_mode, ArchiveMode::Archive) { + if structural { self.upstream_request_pending = true; } } pub fn apply_upstream_batch(&mut self, batch: UpstreamBatch) { - if !matches!(self.archive_mode, ArchiveMode::Archive) { - return; - } let Some(snapshot) = self.snapshot.as_mut() else { return; }; @@ -395,7 +448,7 @@ impl App { self.upstream_request_pending = false; return Some(UpstreamCommand::Cancel); } - if !self.upstream_request_pending || !matches!(self.archive_mode, ArchiveMode::Archive) { + if !self.upstream_request_pending { return None; } self.upstream_request_pending = false; @@ -421,9 +474,6 @@ impl App { let Some(branch) = snapshot.branches.get(index) else { continue; }; - if !self.config.is_archived(&branch.id) { - continue; - } targets.push(UpstreamTarget { branch: branch.id.clone(), oid: branch.oid.clone(), @@ -514,7 +564,7 @@ impl App { } else { self.keep_selected_visible(); } - if previous != self.viewport_height && matches!(self.archive_mode, ArchiveMode::Archive) { + if previous != self.viewport_height { self.upstream_request_pending = true; } } @@ -566,11 +616,17 @@ impl App { } pub fn selected_branch(&self) -> Option<&Branch> { + if self.selected_label.is_some() { + return None; + } let selected = self.selected.as_ref()?; self.snapshot.as_ref()?.branch(selected) } pub fn handle_input(&mut self, input: Input) -> Action { + if matches!(self.overlay, Overlay::StackNameEditor(_)) { + return self.handle_stack_name_editor_key(input.key); + } if input.phase == KeyPhase::Repeat && !input.key.allows_repeat() { return Action::None; } @@ -589,6 +645,9 @@ impl App { if matches!(self.mutation, MutationState::ConfirmingDeletion(_)) { return self.handle_delete_confirmation(key); } + if matches!(self.mutation, MutationState::ConfirmingCheckout(_)) { + return self.handle_checkout_confirmation(key); + } match &self.overlay { Overlay::Search => return self.handle_search_key(key), Overlay::Help => { @@ -602,7 +661,7 @@ impl App { } Overlay::OrderPicker(_) => return self.handle_order_picker_key(key), Overlay::ColorPicker(_) => return self.handle_color_picker_key(key), - Overlay::StackNameEditor(_) => return self.handle_stack_name_editor_key(key), + Overlay::StackNameEditor(_) => unreachable!("name editor handled before global keys"), Overlay::ArchiveRange(_) => return self.handle_archive_range_key(key), Overlay::None => {} } @@ -695,6 +754,15 @@ impl App { self.reproject(); Action::None } + Key::Character('d') => { + self.detail_sidebar = !self.detail_sidebar; + self.message = Some(Arc::from(if self.detail_sidebar { + "detail sidebar enabled" + } else { + "detail sidebar hidden" + })); + Action::None + } Key::Character('a') => { self.toggle_archive_mode(); Action::None @@ -721,6 +789,7 @@ impl App { self.open_stack_name_editor(); Action::None } + Key::Character('i') => self.toggle_visual_section(), Key::Character('o') => self .selected_url() .map(Action::OpenUrl) @@ -730,12 +799,15 @@ impl App { .map(Action::CopyUrl) .unwrap_or(Action::None), Key::Enter if matches!(self.mutation, MutationState::Idle) => { - if let Some(reason) = self.checkout_disabled_reason() { + if let Some(target) = self.selected_label.clone() { + self.open_name_editor_for_target(target); + Action::None + } else if let Some(reason) = self.checkout_disabled_reason() { self.message = Some(Arc::from(reason)); Action::None } else if let Some(branch) = self.selected.clone() { - self.mutation = MutationState::CheckingOut(branch.clone()); - Action::Checkout(branch) + self.mutation = MutationState::ConfirmingCheckout(branch); + Action::None } else { Action::None } @@ -756,28 +828,36 @@ impl App { let Some(selected) = self.selected.as_ref() else { return Action::None; }; - let Some(row) = self.projection.row_for(selected) else { - return Action::None; + let target = if let Some(target) = self.selected_label.clone() { + target + } else { + let Some(row) = self.projection.row_for(selected) else { + return Action::None; + }; + if row.is_trunk { + self.message = Some(Arc::from("trunk rows do not have a stack color")); + return Action::None; + } + if self.config.visual_section(selected).is_some() { + ConfigTarget::VisualSection(selected.clone()) + } else { + ConfigTarget::Stack(row.stack_id.clone()) + } }; - if row.is_trunk { - self.message = Some(Arc::from("trunk rows do not have a stack color")); - return Action::None; - } - let root = row.stack_id.clone(); let common_dir = snapshot.common_dir.clone(); - let next = match self.config.color(&root) { - None => COLOR_OPTIONS[1].1, - Some(current) => COLOR_OPTIONS - .iter() - .position(|(_, value)| *value == Some(current)) - .and_then(|index| COLOR_OPTIONS.get(index + 1)) - .and_then(|(_, value)| *value), - }; - if let Err(error) = self.config.set_color_in_memory(&root, next) { + let current_index = COLOR_OPTIONS + .iter() + .position(|(_, value)| *value == self.target_color(&target)) + .unwrap_or(0); + let next = (1..=COLOR_OPTIONS.len()) + .map(|offset| COLOR_OPTIONS[(current_index + offset) % COLOR_OPTIONS.len()].1) + .find(|value| self.valid_color_choice(&target, *value)) + .flatten(); + if let Err(error) = self.set_target_color_in_memory(&target, next) { self.message = Some(Arc::from(format!("stack color was not changed: {error}"))); return Action::None; } - self.persist_color(root, next.map(Arc::from), common_dir) + self.persist_target_color(target, next.map(Arc::from), common_dir) } fn persist_color( @@ -795,6 +875,31 @@ impl App { Action::PersistConfig(self.register_config_mutation(mutation, common_dir)) } + fn persist_target_color( + &mut self, + target: ConfigTarget, + value: Option>, + common_dir: PathBuf, + ) -> Action { + match target { + ConfigTarget::Stack(stack) => self.persist_color(stack, value, common_dir), + ConfigTarget::VisualSection(anchor) => { + let Some(mut section) = self.config.visual_section(&anchor).cloned() else { + return Action::None; + }; + let Some(value) = value else { + self.message = Some(Arc::from("visual sections require a concrete color")); + return Action::None; + }; + section.color = value.to_string(); + let mut mutation = ConfigMutation::default(); + mutation.set_visual_section(anchor, Some(section)); + self.message = Some(Arc::from("section color changed; saving")); + Action::PersistConfig(self.register_config_mutation(mutation, common_dir)) + } + } + } + fn adjust_lane_pitch(&mut self, delta: i16) { let next = self.lane_pitch.adjust(delta); if next == self.lane_pitch { @@ -806,6 +911,71 @@ impl App { } } + fn toggle_visual_section(&mut self) -> Action { + if self.selected_label.is_some() { + self.message = Some(Arc::from( + "visual section boundaries can only be toggled on branches", + )); + return Action::None; + } + let Some(anchor) = self.selected.clone() else { + return Action::None; + }; + let Some(row) = self.projection.row_for(&anchor) else { + return Action::None; + }; + if row.is_trunk { + self.message = Some(Arc::from("trunk rows cannot start visual sections")); + return Action::None; + } + let Some(common_dir) = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.common_dir.clone()) + else { + return Action::None; + }; + let mut mutation = ConfigMutation::default(); + if self.config.visual_section(&anchor).is_some() { + if let Err(error) = self.config.set_visual_section_in_memory(&anchor, None) { + self.message = Some(Arc::from(format!("section was not removed: {error}"))); + return Action::None; + } + mutation.set_visual_section(anchor, None); + self.message = Some(Arc::from("visual section removed; saving")); + } else { + let used = self.adjacent_section_colors(&anchor, &row.stack_id); + let choices: Vec<_> = COLOR_OPTIONS + .iter() + .filter_map(|(_, value)| *value) + .collect(); + let start = anchor + .0 + .bytes() + .fold(0usize, |sum, byte| sum.wrapping_add(byte as usize)) + % choices.len(); + let color = (0..choices.len()) + .map(|offset| choices[(start + offset) % choices.len()]) + .find(|color| !used.iter().any(|used| used == color)) + .unwrap_or(choices[start]); + let section = VisualSection { + color: color.to_owned(), + name: None, + }; + if let Err(error) = self + .config + .set_visual_section_in_memory(&anchor, Some(section.clone())) + { + self.message = Some(Arc::from(format!("section was not created: {error}"))); + return Action::None; + } + mutation.set_visual_section(anchor, Some(section)); + self.message = Some(Arc::from("visual section created; saving")); + } + self.reproject(); + Action::PersistConfig(self.register_config_mutation(mutation, common_dir)) + } + fn handle_order_picker_key(&mut self, key: Key) -> Action { let Overlay::OrderPicker(mut picker) = self.overlay.clone() else { return Action::None; @@ -838,15 +1008,23 @@ impl App { let Some(selected) = self.selected.as_ref() else { return; }; - let Some(row) = self.projection.row_for(selected) else { - return; + let target = if let Some(target) = self.selected_label.clone() { + target + } else { + let Some(row) = self.projection.row_for(selected) else { + return; + }; + if row.is_trunk { + self.message = Some(Arc::from("trunk rows do not have a stack color")); + return; + } + if self.config.visual_section(selected).is_some() { + ConfigTarget::VisualSection(selected.clone()) + } else { + ConfigTarget::Stack(row.stack_id.clone()) + } }; - if row.is_trunk { - self.message = Some(Arc::from("trunk rows do not have a stack color")); - return; - } - let target = row.stack_id.clone(); - let original = self.config.color(&target).map(Arc::from); + let original = self.target_color(&target).map(Arc::from); let choice_index = COLOR_OPTIONS .iter() .position(|(_, value)| *value == original.as_deref()) @@ -865,15 +1043,32 @@ impl App { }; match key { Key::Up | Key::Down => { - picker.choice_index = if key == Key::Up { - picker.choice_index.saturating_sub(1) - } else { - (picker.choice_index + 1).min(COLOR_OPTIONS.len() - 1) - }; + let direction = if key == Key::Up { -1 } else { 1 }; + let original_index = picker.choice_index; + for _ in 0..COLOR_OPTIONS.len() { + picker.choice_index = picker + .choice_index + .saturating_add_signed(direction) + .min(COLOR_OPTIONS.len() - 1); + if self.valid_color_choice(&picker.target, COLOR_OPTIONS[picker.choice_index].1) + { + break; + } + if picker.choice_index == 0 && direction < 0 + || picker.choice_index + 1 == COLOR_OPTIONS.len() && direction > 0 + { + if !self.valid_color_choice( + &picker.target, + COLOR_OPTIONS[picker.choice_index].1, + ) { + picker.choice_index = original_index; + } + break; + } + } picker.pending = COLOR_OPTIONS[picker.choice_index].1.map(Arc::from); - if let Err(error) = self - .config - .set_color_in_memory(&picker.target, picker.pending.as_deref()) + if let Err(error) = + self.set_target_color_in_memory(&picker.target, picker.pending.as_deref()) { self.overlay = Overlay::None; self.message = Some(Arc::from(format!( @@ -881,11 +1076,12 @@ impl App { ))); return Action::None; } + self.reproject(); self.overlay = Overlay::ColorPicker(picker); Action::None } Key::Enter => { - if !self.stack_target_is_valid(&picker.target) { + if !self.config_target_is_valid(&picker.target) { self.close_invalid_color_picker(&picker); return Action::None; } @@ -898,18 +1094,18 @@ impl App { return Action::None; }; self.overlay = Overlay::None; - self.persist_color(picker.target, picker.pending, common_dir) + self.persist_target_color(picker.target, picker.pending, common_dir) } Key::Escape => { - if let Err(error) = self - .config - .set_color_in_memory(&picker.target, picker.original.as_deref()) + if let Err(error) = + self.set_target_color_in_memory(&picker.target, picker.original.as_deref()) { self.message = Some(Arc::from(format!( "color preview could not be restored: {error}" ))); } self.overlay = Overlay::None; + self.reproject(); Action::None } _ => Action::None, @@ -917,6 +1113,10 @@ impl App { } fn open_stack_name_editor(&mut self) { + if self.selected_label.is_some() { + self.message = Some(Arc::from("press Enter to edit the selected name")); + return; + } let Some(selected) = self.selected.as_ref() else { return; }; @@ -927,13 +1127,60 @@ impl App { self.message = Some(Arc::from("trunk rows cannot be named as stacks")); return; } - let target = row.stack_id.clone(); - let draft = self - .config - .stack_name(&target) - .unwrap_or_default() - .to_owned(); - self.overlay = Overlay::StackNameEditor(StackNameEditor { target, draft }); + let target = if let Some(section) = self.config.visual_section(selected) { + if section.name.is_some() { + self.message = Some(Arc::from("select the section name and press Enter to edit")); + return; + } + ConfigTarget::VisualSection(selected.clone()) + } else { + let stack = row.stack_id.clone(); + if self.config.stack_name(&stack).is_some() { + self.message = Some(Arc::from("select the stack name and press Enter to edit")); + return; + } + ConfigTarget::Stack(stack) + }; + let draft = match &target { + ConfigTarget::Stack(stack) => self.config.stack_name(stack), + ConfigTarget::VisualSection(anchor) => self + .config + .visual_section(anchor) + .and_then(|section| section.name.as_deref()), + } + .unwrap_or_default() + .to_owned(); + let cursor = draft.chars().count(); + self.overlay = Overlay::StackNameEditor(StackNameEditor { + target: target.clone(), + draft, + cursor, + }); + self.selected_label = Some(target.clone()); + self.reproject(); + } + + fn open_name_editor_for_target(&mut self, target: ConfigTarget) { + if !self.config_target_is_valid(&target) { + return; + } + let draft = match &target { + ConfigTarget::Stack(stack) => self.config.stack_name(stack), + ConfigTarget::VisualSection(anchor) => self + .config + .visual_section(anchor) + .and_then(|section| section.name.as_deref()), + } + .unwrap_or_default() + .to_owned(); + let cursor = draft.chars().count(); + self.overlay = Overlay::StackNameEditor(StackNameEditor { + target: target.clone(), + draft, + cursor, + }); + self.selected_label = Some(target); + self.reproject(); } fn handle_stack_name_editor_key(&mut self, key: Key) -> Action { @@ -942,24 +1189,80 @@ impl App { }; match key { Key::Escape => { + let existed = match &editor.target { + ConfigTarget::Stack(stack) => self.config.stack_name(stack).is_some(), + ConfigTarget::VisualSection(anchor) => self + .config + .visual_section(anchor) + .is_some_and(|section| section.name.is_some()), + }; self.overlay = Overlay::None; + if !existed { + self.selected = Some(editor.target.branch().clone()); + self.selected_label = None; + } + self.reproject(); Action::None } Key::Backspace => { - editor.draft.pop(); + if editor.cursor > 0 { + let mut characters = editor.draft.chars().collect::>(); + characters.remove(editor.cursor - 1); + editor.cursor -= 1; + editor.draft = characters.into_iter().collect(); + } + self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); + Action::None + } + Key::Delete => { + let mut characters = editor.draft.chars().collect::>(); + if editor.cursor < characters.len() { + characters.remove(editor.cursor); + editor.draft = characters.into_iter().collect(); + } + self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); + Action::None + } + Key::Left => { + editor.cursor = editor.cursor.saturating_sub(1); + self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); + Action::None + } + Key::Right => { + editor.cursor = (editor.cursor + 1).min(editor.draft.chars().count()); + self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); + Action::None + } + Key::Home => { + editor.cursor = 0; self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); + Action::None + } + Key::End => { + editor.cursor = editor.draft.chars().count(); + self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); Action::None } Key::Character(character) if !character.is_control() && editor.draft.chars().count() < MAX_STACK_NAME_CHARS => { - editor.draft.push(character); + let mut characters = editor.draft.chars().collect::>(); + characters.insert(editor.cursor, character); + editor.cursor += 1; + editor.draft = characters.into_iter().collect(); self.overlay = Overlay::StackNameEditor(editor); + self.reproject(); Action::None } Key::Enter => { - if !self.stack_target_is_valid(&editor.target) { + if !self.config_target_is_valid(&editor.target) { self.close_invalid_stack_name_editor(); return Action::None; } @@ -975,20 +1278,35 @@ impl App { self.close_invalid_stack_name_editor(); return Action::None; }; - if let Err(error) = self - .config - .set_stack_name_in_memory(&editor.target, value.as_deref()) - { + let update = self.set_target_name_in_memory(&editor.target, value.as_deref()); + if let Err(error) = update { self.overlay = Overlay::None; self.message = Some(Arc::from(format!("stack name was not changed: {error}"))); return Action::None; } let mut mutation = ConfigMutation::default(); - mutation.set_stack_name(editor.target.clone(), value.clone()); + match &editor.target { + ConfigTarget::Stack(stack) => { + mutation.set_stack_name(stack.clone(), value.clone()) + } + ConfigTarget::VisualSection(anchor) => { + let mut section = self + .config + .visual_section(anchor) + .cloned() + .expect("valid section editor target"); + section.name = value.as_deref().map(str::to_owned); + mutation.set_visual_section(anchor.clone(), Some(section)); + } + } self.overlay = Overlay::None; + if value.is_none() { + self.selected = Some(editor.target.branch().clone()); + self.selected_label = None; + } self.message = Some(Arc::from(match value { - Some(name) => format!("stack named {name}; saving"), - None => "stack name cleared; saving".to_owned(), + Some(name) => format!("name set to {name}; saving"), + None => "name cleared; saving".to_owned(), })); self.reproject(); Action::PersistConfig(self.register_config_mutation(mutation, common_dir)) @@ -1000,10 +1318,9 @@ impl App { fn revalidate_overlay(&mut self) { match self.overlay.clone() { Overlay::ColorPicker(picker) => { - if self.stack_target_is_valid(&picker.target) { - if let Err(error) = self - .config - .set_color_in_memory(&picker.target, picker.pending.as_deref()) + if self.config_target_is_valid(&picker.target) { + if let Err(error) = + self.set_target_color_in_memory(&picker.target, picker.pending.as_deref()) { self.overlay = Overlay::None; self.message = Some(Arc::from(format!( @@ -1015,7 +1332,7 @@ impl App { } } Overlay::StackNameEditor(editor) => { - if !self.stack_target_is_valid(&editor.target) { + if !self.config_target_is_valid(&editor.target) { self.close_invalid_stack_name_editor(); } } @@ -1036,10 +1353,164 @@ impl App { }) } + fn config_target_is_valid(&self, target: &ConfigTarget) -> bool { + match target { + ConfigTarget::Stack(stack) => self.stack_target_is_valid(stack), + ConfigTarget::VisualSection(anchor) => { + self.config.visual_section(anchor).is_some() + && self + .snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.branch(anchor).is_some()) + && self + .topology + .as_ref() + .is_some_and(|topology| !topology.is_trunk(anchor)) + } + } + } + + fn target_color<'a>(&'a self, target: &'a ConfigTarget) -> Option<&'a str> { + match target { + ConfigTarget::Stack(stack) => self.config.color(stack), + ConfigTarget::VisualSection(anchor) => self + .config + .visual_section(anchor) + .map(|section| section.color.as_str()), + } + } + + fn valid_color_choice(&self, target: &ConfigTarget, value: Option<&str>) -> bool { + match target { + ConfigTarget::Stack(stack) => value.is_none_or(|color| { + !self + .adjacent_section_colors(stack, stack) + .iter() + .any(|used| used == color) + }), + ConfigTarget::VisualSection(anchor) => value.is_some_and(|color| { + let stack = self + .topology + .as_ref() + .and_then(|topology| topology.stack_for(anchor)) + .unwrap_or(anchor); + !self + .adjacent_section_colors(anchor, stack) + .iter() + .any(|used| used == color) + }), + } + } + + fn adjacent_section_colors(&self, anchor: &BranchId, stack: &BranchId) -> Vec { + let mut boundaries: Vec<_> = self + .projection + .rows + .iter() + .filter_map(|row| { + let boundary = row.visual_section.as_ref()?; + let color = row.visual_color.as_ref()?; + (row.stack_id == *stack) + .then(|| (row.manual_depth, boundary.clone(), color.to_string())) + }) + .collect(); + boundaries.sort_by_key(|(depth, _, _)| *depth); + boundaries.dedup_by(|left, right| left.1 == right.1); + if anchor == stack { + return boundaries + .first() + .map(|(_, _, color)| vec![color.clone()]) + .unwrap_or_default(); + } + let anchor_depth = boundaries + .iter() + .find(|(_, boundary, _)| boundary == anchor) + .map(|(depth, _, _)| *depth) + .or_else(|| { + self.projection + .row_for(anchor) + .map(|row| row.manual_depth.saturating_add(1)) + }) + .unwrap_or(1); + let mut colors = Vec::with_capacity(2); + if let Some((_, _, color)) = boundaries.iter().find(|(depth, boundary, _)| { + *depth == anchor_depth.saturating_add(1) && boundary != anchor + }) { + colors.push(color.clone()); + } + if let Some((_, _, color)) = boundaries + .iter() + .find(|(depth, boundary, _)| *depth + 1 == anchor_depth && boundary != anchor) + { + colors.push(color.clone()); + } else { + colors.push(self.stack_color_hex(stack).to_owned()); + } + colors + } + + fn stack_color_hex<'a>(&'a self, stack: &'a BranchId) -> &'a str { + if let Some(color) = self.config.color(stack) { + return color; + } + const COLORS: [&str; 8] = [ + "#7aa2f7", "#bb9af7", "#7dcfff", "#ff9e64", "#9ece6a", "#f7768e", "#2ac3de", "#c0caf5", + ]; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.snapshot + .as_ref() + .map(|snapshot| snapshot.repository_id.as_ref()) + .unwrap_or_default() + .hash(&mut hasher); + stack.hash(&mut hasher); + COLORS[hasher.finish() as usize % COLORS.len()] + } + + fn set_target_color_in_memory( + &mut self, + target: &ConfigTarget, + color: Option<&str>, + ) -> anyhow::Result<()> { + match target { + ConfigTarget::Stack(stack) => self.config.set_color_in_memory(stack, color), + ConfigTarget::VisualSection(anchor) => { + let mut section = self + .config + .visual_section(anchor) + .cloned() + .ok_or_else(|| anyhow::anyhow!("visual section no longer exists"))?; + let color = color + .ok_or_else(|| anyhow::anyhow!("visual sections require a concrete color"))?; + section.color = color.to_owned(); + self.config + .set_visual_section_in_memory(anchor, Some(section)) + } + } + } + + fn set_target_name_in_memory( + &mut self, + target: &ConfigTarget, + name: Option<&str>, + ) -> anyhow::Result<()> { + match target { + ConfigTarget::Stack(stack) => self.config.set_stack_name_in_memory(stack, name), + ConfigTarget::VisualSection(anchor) => { + let mut section = self + .config + .visual_section(anchor) + .cloned() + .ok_or_else(|| anyhow::anyhow!("visual section no longer exists"))?; + section.name = name.map(str::to_owned); + self.config + .set_visual_section_in_memory(anchor, Some(section)) + } + } + } + fn close_invalid_color_picker(&mut self, picker: &ColorPicker) { - if let Err(error) = self - .config - .set_color_in_memory(&picker.target, picker.original.as_deref()) + if let Err(error) = + self.set_target_color_in_memory(&picker.target, picker.original.as_deref()) { self.message = Some(Arc::from(format!( "color preview could not be restored: {error}" @@ -1066,6 +1537,8 @@ impl App { .retain(|_, (pending_sequence, _)| *pending_sequence > sequence); self.pending_stack_names .retain(|_, (pending_sequence, _)| *pending_sequence > sequence); + self.pending_visual_sections + .retain(|_, (pending_sequence, _)| *pending_sequence > sequence); } if sequence != self.latest_config_sequence { return; @@ -1120,6 +1593,10 @@ impl App { for (stack, name) in mutation.stack_name_updates { self.pending_stack_names.insert(stack, (sequence, name)); } + for (anchor, section) in mutation.visual_section_updates { + self.pending_visual_sections + .insert(anchor, (sequence, section)); + } ConfigWriteRequest { sequence, common_dir, @@ -1145,6 +1622,11 @@ impl App { .iter() .map(|(stack, (_, name))| (stack.clone(), name.clone())) .collect(), + visual_section_updates: self + .pending_visual_sections + .iter() + .map(|(anchor, (_, section))| (anchor.clone(), section.clone())) + .collect(), } } @@ -1166,7 +1648,8 @@ impl App { } ArchiveMode::Archive => { self.archive_mode = ArchiveMode::Active; - self.upstream_cancel_pending = true; + self.upstream_request_pending = true; + self.upstream_cancel_pending = false; self.last_upstream_working_set = None; self.reproject(); if let Some(state) = self.active_view_state.take() { @@ -1187,6 +1670,10 @@ impl App { } fn toggle_selected_archive(&mut self) -> Action { + if self.selected_label.is_some() { + self.message = Some(Arc::from("labels cannot be archived")); + return Action::None; + } let Some(branch_id) = self.selected.clone() else { return Action::None; }; @@ -1224,9 +1711,7 @@ impl App { })); self.selected = selection_after; self.reproject(); - if matches!(self.archive_mode, ArchiveMode::Archive) { - self.upstream_request_pending = true; - } + self.upstream_request_pending = true; Action::PersistConfig(self.register_config_mutation(mutation, common_dir)) } @@ -1250,6 +1735,10 @@ impl App { } fn begin_archive_range(&mut self) { + if self.selected_label.is_some() { + self.message = Some(Arc::from("archive ranges must start on a branch")); + return; + } let Some(anchor) = self.selected.clone() else { return; }; @@ -1406,6 +1895,7 @@ impl App { .. } => Some(request.branch.clone()), MutationState::Idle + | MutationState::ConfirmingCheckout(_) | MutationState::CheckingOut(_) | MutationState::Reconciling { .. } | MutationState::DeletionBlocked(_) => None, @@ -1427,6 +1917,9 @@ impl App { if self.config.stack_name(target).is_some() { mutation.set_stack_name(target.clone(), None); } + if self.config.visual_section(target).is_some() { + mutation.set_visual_section(target.clone(), None); + } if mutation.is_empty() { return; } @@ -1489,6 +1982,39 @@ impl App { self.queued_config_write = Some(self.register_config_mutation(mutation, common_dir)); } + fn prune_invalid_visual_sections(&mut self) { + let (Some(snapshot), Some(topology)) = (self.snapshot.as_ref(), self.topology.as_ref()) + else { + return; + }; + let invalid: Vec<_> = self + .config + .visual_sections + .keys() + .map(|anchor| BranchId::new(anchor.clone())) + .filter(|anchor| { + snapshot.branch(anchor).is_none() + || topology.is_trunk(anchor) + || topology.stack_for(anchor).is_none() + }) + .collect(); + if invalid.is_empty() { + return; + } + let common_dir = snapshot.common_dir.clone(); + let mut mutation = ConfigMutation::default(); + for anchor in invalid { + mutation.set_visual_section(anchor, None); + } + if let Err(error) = self.config.apply_mutation_in_memory(&mutation) { + self.message = Some(Arc::from(format!( + "invalid visual sections could not be pruned: {error}" + ))); + return; + } + self.queued_config_write = Some(self.register_config_mutation(mutation, common_dir)); + } + fn nearby_selection_after(&self, target: &BranchId) -> Option { if self.selected.as_ref() != Some(target) { return None; @@ -1527,6 +2053,36 @@ impl App { Action::None } + fn handle_checkout_confirmation(&mut self, key: Key) -> Action { + let MutationState::ConfirmingCheckout(target) = &self.mutation else { + return Action::None; + }; + let target = target.clone(); + match key { + Key::Enter + if self.selected.as_ref() == Some(&target) && self.selected_label.is_none() => + { + if let Some(reason) = self.checkout_disabled_reason() { + self.mutation = MutationState::Idle; + self.message = Some(Arc::from(reason)); + Action::None + } else { + self.mutation = MutationState::CheckingOut(target.clone()); + Action::Checkout(target) + } + } + Key::Enter | Key::Escape => { + self.mutation = MutationState::Idle; + self.message = Some(Arc::from("checkout cancelled")); + Action::None + } + _ => { + self.mutation = MutationState::Idle; + self.handle_normal_key(key) + } + } + } + fn selected_url(&self) -> Option> { if matches!(self.archive_mode, ArchiveMode::Archive) { return None; @@ -1535,6 +2091,10 @@ impl App { } pub fn begin_delete_confirmation(&mut self) { + if self.selected_label.is_some() { + self.message = Some(Arc::from("labels cannot be deleted")); + return; + } if !matches!(self.mutation, MutationState::Idle) { self.message = Some(Arc::from("another repository mutation is active")); return; @@ -1656,6 +2216,9 @@ impl App { pub fn mutation_progress(&self) -> Option { match &self.mutation { + MutationState::ConfirmingCheckout(target) => { + Some(format!("checkout {target}? Enter confirm · Esc cancel")) + } MutationState::CheckingOut(target) => Some(format!("switching to {target}…")), MutationState::Deleting(confirmation) => { Some(format!("deleting {} locally…", confirmation.request.branch)) @@ -1750,6 +2313,46 @@ impl App { return; }; let scope = self.resolved_scope(); + let mut stack_names: HashMap> = self + .config + .stack_names + .iter() + .map(|(stack, name)| (BranchId::new(stack.clone()), Arc::from(name.as_str()))) + .collect(); + let mut visual_sections: HashMap = + self.config + .visual_sections + .iter() + .map(|(anchor, section)| { + ( + BranchId::new(anchor.clone()), + crate::model::topology::VisualSectionSpec { + color: Arc::from(section.color.as_str()), + name: section.name.as_deref().map(Arc::from), + }, + ) + }) + .collect(); + if let Overlay::StackNameEditor(editor) = &self.overlay { + match &editor.target { + ConfigTarget::Stack(stack) => { + stack_names.insert(stack.clone(), Arc::from(editor.draft.as_str())); + } + ConfigTarget::VisualSection(anchor) => { + if let Some(section) = visual_sections.get_mut(anchor) { + section.name = Some(Arc::from(editor.draft.as_str())); + } + } + } + } + let stack_colors = visual_sections + .keys() + .filter_map(|anchor| topology.stack_for(anchor).cloned()) + .map(|stack| { + let color = Arc::from(self.stack_color_hex(&stack)); + (stack, color) + }) + .collect(); self.projection = topology.project(&ProjectionOptions { order: self.order_mode, scope, @@ -1763,12 +2366,9 @@ impl App { .cloned() .map(BranchId::new) .collect(), - stack_names: self - .config - .stack_names - .iter() - .map(|(stack, name)| (BranchId::new(stack.clone()), Arc::from(name.as_str()))) - .collect(), + stack_names, + stack_colors, + visual_sections, }); if matches!(self.scope, ViewScope::Untrunked) { self.restrict_projection_to_untrunked(); @@ -1864,6 +2464,10 @@ impl App { } fn toggle_stack_scope(&mut self) { + if self.selected_label.is_some() { + self.message = Some(Arc::from("select a branch to focus its stack")); + return; + } let Some(selected) = self.selected.clone() else { return; }; @@ -1884,6 +2488,10 @@ impl App { } fn toggle_trunk_scope(&mut self) { + if self.selected_label.is_some() { + self.message = Some(Arc::from("select a branch to focus its trunk")); + return; + } let Some(selected) = self.selected.clone() else { return; }; @@ -1969,6 +2577,8 @@ impl App { else { self.projection.selectable.clear(); self.projection.selectable_visual_rows.clear(); + self.projection.navigation.clear(); + self.projection.navigation_visual_rows.clear(); self.projection.branch_to_selectable.clear(); self.projection.branch_to_visual.clear(); self.projection.stack_heads.clear(); @@ -1996,6 +2606,22 @@ impl App { } self.projection.selectable = selectable; self.projection.selectable_visual_rows = selectable_rows; + let mut navigation = Vec::new(); + let mut navigation_rows = Vec::new(); + for (target, row) in self + .projection + .navigation + .iter() + .cloned() + .zip(self.projection.navigation_visual_rows.iter().copied()) + { + if row >= range.start && row <= range.end { + navigation.push(target); + navigation_rows.push(row); + } + } + self.projection.navigation = navigation; + self.projection.navigation_visual_rows = navigation_rows; self.projection.branch_to_selectable = self .projection .selectable @@ -2073,6 +2699,27 @@ impl App { } fn reconcile_selection(&mut self) { + if let Some(label) = &self.selected_label { + let visible = self + .projection + .navigation + .iter() + .any(|target| match (target, label) { + ( + crate::model::topology::SelectionTarget::StackLabel(left), + ConfigTarget::Stack(right), + ) => left == right, + ( + crate::model::topology::SelectionTarget::VisualSectionLabel(left), + ConfigTarget::VisualSection(right), + ) => left == right, + _ => false, + }); + if !visible { + self.selected = Some(label.branch().clone()); + self.selected_label = None; + } + } if self.selected.is_none() { self.selected = self.snapshot.as_ref().and_then(|snapshot| { snapshot @@ -2095,18 +2742,43 @@ impl App { } fn move_selection(&mut self, delta: isize) { - if self.projection.selectable.is_empty() { + if self.projection.navigation.is_empty() { return; } let current = self - .selected - .as_ref() - .and_then(|selected| self.projection.branch_to_selectable.get(selected).copied()) + .projection + .navigation + .iter() + .position(|target| match target { + crate::model::topology::SelectionTarget::Branch(branch) => { + self.selected_label.is_none() && self.selected.as_ref() == Some(branch) + } + crate::model::topology::SelectionTarget::StackLabel(stack) => { + self.selected_label.as_ref() == Some(&ConfigTarget::Stack(stack.clone())) + } + crate::model::topology::SelectionTarget::VisualSectionLabel(anchor) => { + self.selected_label.as_ref() + == Some(&ConfigTarget::VisualSection(anchor.clone())) + } + }) .unwrap_or(0); let next = current .saturating_add_signed(delta) - .min(self.projection.selectable.len() - 1); - self.selected = Some(self.projection.selectable[next].clone()); + .min(self.projection.navigation.len() - 1); + match self.projection.navigation[next].clone() { + crate::model::topology::SelectionTarget::Branch(branch) => { + self.selected = Some(branch); + self.selected_label = None; + } + crate::model::topology::SelectionTarget::StackLabel(stack) => { + self.selected = Some(stack.clone()); + self.selected_label = Some(ConfigTarget::Stack(stack)); + } + crate::model::topology::SelectionTarget::VisualSectionLabel(anchor) => { + self.selected = Some(anchor.clone()); + self.selected_label = Some(ConfigTarget::VisualSection(anchor)); + } + } self.keep_selected_visible(); } @@ -2137,17 +2809,44 @@ impl App { .iter() .rev() .find(|head| head.visual_row < current_row && head.stack_id != current_stack) + .map(|head| head.branch.clone()) } else { - self.projection - .stack_heads + let section = self + .projection + .section_ranges .iter() - .find(|head| head.visual_row > current_row && head.stack_id != current_stack) + .find(|range| current_row >= range.start && current_row <= range.end); + let next_stack = self.projection.stack_heads.iter().find(|head| { + head.visual_row > current_row + && head.stack_id != current_stack + && section.is_none_or(|range| head.visual_row <= range.end) + && section + .and_then(|range| range.trunk_row) + .is_none_or(|trunk_row| head.visual_row < trunk_row) + }); + next_stack.map(|head| head.branch.clone()).or_else(|| { + section + .and_then(|range| range.trunk_row) + .filter(|trunk_row| *trunk_row > current_row) + .and_then(|trunk_row| self.projection.entries.get(trunk_row)) + .and_then(|entry| match entry { + crate::model::topology::ProjectionEntry::Branch(row) => { + Some(row.branch.clone()) + } + _ => None, + }) + }) }; let Some(target) = target else { return; }; - self.selected = Some(target.branch.clone()); - self.keep_selected_visible(); + self.selected = Some(target); + self.selected_label = None; + if self.selected_visual_row() == self.sticky_visual_row() { + self.align_focused_bottom(); + } else { + self.keep_selected_visible(); + } } fn move_section(&mut self, delta: isize) { @@ -2185,23 +2884,20 @@ impl App { return; }; self.selected = Some(branch.clone()); + self.selected_label = None; self.keep_selected_visible(); } fn keep_selected_visible(&mut self) { let previous_scroll = self.scroll; self.keep_selected_visible_inner(); - if self.scroll != previous_scroll && matches!(self.archive_mode, ArchiveMode::Archive) { + if self.scroll != previous_scroll { self.upstream_request_pending = true; } } fn keep_selected_visible_inner(&mut self) { - let Some(index) = self - .selected - .as_ref() - .and_then(|selected| self.projection.branch_to_visual.get(selected).copied()) - else { + let Some(index) = self.selected_visual_row() else { self.scroll = 0; return; }; @@ -2234,6 +2930,34 @@ impl App { ); } + fn selected_visual_row(&self) -> Option { + if let Some(label) = &self.selected_label { + return self + .projection + .navigation + .iter() + .zip(&self.projection.navigation_visual_rows) + .find_map(|(target, row)| match (target, label) { + ( + crate::model::topology::SelectionTarget::StackLabel(left), + ConfigTarget::Stack(right), + ) if left == right => Some(*row), + ( + crate::model::topology::SelectionTarget::VisualSectionLabel(left), + ConfigTarget::VisualSection(right), + ) if left == right => Some(*row), + _ => None, + }); + } + self.selected + .as_ref() + .and_then(|selected| self.projection.branch_to_visual.get(selected).copied()) + } + + pub fn selected_visual_row_for_ui(&self) -> Option { + self.selected_visual_row() + } + fn align_focused_bottom(&mut self) { let (Some((start, _)), Some(sticky)) = (self.focused_section_bounds(), self.sticky_visual_row()) diff --git a/src/app/state.rs b/src/app/state.rs index b54a3cc..fd30100 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -103,6 +103,7 @@ pub struct DeleteConfirmation { pub enum MutationState { #[default] Idle, + ConfirmingCheckout(BranchId), CheckingOut(BranchId), ConfirmingDeletion(DeleteConfirmation), Deleting(DeleteConfirmation), @@ -168,7 +169,7 @@ pub struct OrderPicker { #[derive(Clone, Debug, Eq, PartialEq)] pub struct ColorPicker { - pub target: BranchId, + pub target: ConfigTarget, pub original: Option>, pub pending: Option>, pub choice_index: usize, @@ -176,8 +177,29 @@ pub struct ColorPicker { #[derive(Clone, Debug, Eq, PartialEq)] pub struct StackNameEditor { - pub target: BranchId, + pub target: ConfigTarget, pub draft: String, + pub cursor: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigTarget { + Stack(BranchId), + VisualSection(BranchId), +} + +impl ConfigTarget { + pub fn branch(&self) -> &BranchId { + match self { + Self::Stack(branch) | Self::VisualSection(branch) => branch, + } + } +} + +impl std::fmt::Display for ConfigTarget { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.branch().fmt(formatter) + } } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/src/benchmark_impl.rs b/src/benchmark_impl.rs index 60a7fc8..fa35ca8 100644 --- a/src/benchmark_impl.rs +++ b/src/benchmark_impl.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; @@ -52,6 +53,7 @@ fn snapshot(count: usize, stack_size: usize) -> RepositorySnapshot { graphite_children: Arc::from([]), branches: Arc::from(branches), branch_index, + stack_diffs: Arc::new(HashMap::new()), state: RepositoryState::Ready, graphite_status: Arc::from("bench"), stale_error: None, diff --git a/src/config.rs b/src/config.rs index ad54e7e..b93e8ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,13 @@ use crate::model::BranchId; pub const MAX_STACK_NAME_CHARS: usize = 80; +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct VisualSection { + pub color: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct Config { #[serde(default)] @@ -21,6 +28,8 @@ pub struct Config { pub archived: BTreeSet, #[serde(default)] pub stack_names: BTreeMap, + #[serde(default)] + pub visual_sections: BTreeMap, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -34,6 +43,7 @@ pub struct ConfigMutation { pub color_updates: BTreeMap>>, pub archive_updates: BTreeMap, pub stack_name_updates: BTreeMap>>, + pub visual_section_updates: BTreeMap>, } impl ConfigMutation { @@ -50,6 +60,10 @@ impl ConfigMutation { self.stack_name_updates.insert(stack, name); } + pub fn set_visual_section(&mut self, anchor: BranchId, section: Option) { + self.visual_section_updates.insert(anchor, section); + } + pub fn prune_archived(&mut self, branches: impl IntoIterator) { self.archive_updates.extend( branches @@ -62,12 +76,15 @@ impl ConfigMutation { self.color_updates.extend(newer.color_updates); self.archive_updates.extend(newer.archive_updates); self.stack_name_updates.extend(newer.stack_name_updates); + self.visual_section_updates + .extend(newer.visual_section_updates); } pub fn is_empty(&self) -> bool { self.color_updates.is_empty() && self.archive_updates.is_empty() && self.stack_name_updates.is_empty() + && self.visual_section_updates.is_empty() } } @@ -84,6 +101,12 @@ impl Config { for name in config.stack_names.values() { validate_stack_name(name)?; } + for section in config.visual_sections.values() { + validate_color(§ion.color)?; + if let Some(name) = §ion.name { + validate_stack_name(name)?; + } + } Ok(config) } @@ -99,6 +122,27 @@ impl Config { self.stack_names.get(stack.0.as_ref()).map(String::as_str) } + pub fn visual_section(&self, anchor: &BranchId) -> Option<&VisualSection> { + self.visual_sections.get(anchor.0.as_ref()) + } + + pub fn set_visual_section_in_memory( + &mut self, + anchor: &BranchId, + section: Option, + ) -> Result<()> { + if let Some(section) = section { + validate_color(§ion.color)?; + if let Some(name) = §ion.name { + validate_stack_name(name)?; + } + self.visual_sections.insert(anchor.0.to_string(), section); + } else { + self.visual_sections.remove(anchor.0.as_ref()); + } + Ok(()) + } + pub fn set_stack_name_in_memory(&mut self, stack: &BranchId, name: Option<&str>) -> Result<()> { let name = name.map(str::trim).filter(|name| !name.is_empty()); if let Some(name) = name { @@ -172,6 +216,7 @@ impl Config { color_updates: updates.clone(), archive_updates: BTreeMap::new(), stack_name_updates: BTreeMap::new(), + visual_section_updates: BTreeMap::new(), }; Self::persist_mutation(common_dir, &mutation, fallback) } @@ -208,6 +253,12 @@ impl Config { for name in mutation.stack_name_updates.values().flatten() { validate_stack_name(name)?; } + for section in mutation.visual_section_updates.values().flatten() { + validate_color(§ion.color)?; + if let Some(name) = §ion.name { + validate_stack_name(name)?; + } + } for (root, color) in &mutation.color_updates { if let Some(color) = color { self.colors.insert(root.0.to_string(), color.to_string()); @@ -228,6 +279,9 @@ impl Config { for (stack, name) in &mutation.stack_name_updates { self.set_stack_name_in_memory(stack, name.as_deref())?; } + for (anchor, section) in &mutation.visual_section_updates { + self.set_visual_section_in_memory(anchor, section.clone())?; + } Ok(()) } @@ -444,6 +498,63 @@ mod tests { assert_eq!(config.stack_name(&stack), Some("trimmed name")); } + #[test] + fn visual_sections_round_trip_and_are_removed_atomically() { + let directory = tempdir().unwrap(); + let anchor = BranchId::new("feature-two"); + let section = VisualSection { + color: "#7dcfff".to_owned(), + name: Some("Payments".to_owned()), + }; + let mut create = ConfigMutation::default(); + create.set_visual_section(anchor.clone(), Some(section.clone())); + Config::persist_mutation(directory.path(), &create, &Config::default()).unwrap(); + assert_eq!( + Config::load(directory.path()) + .unwrap() + .visual_section(&anchor), + Some(§ion) + ); + + let mut remove = ConfigMutation::default(); + remove.set_visual_section(anchor.clone(), None); + Config::persist_mutation(directory.path(), &remove, &Config::default()).unwrap(); + assert_eq!( + Config::load(directory.path()) + .unwrap() + .visual_section(&anchor), + None + ); + } + + #[test] + fn visual_section_names_and_colors_are_validated() { + let mut config = Config::default(); + let anchor = BranchId::new("feature-two"); + assert!( + config + .set_visual_section_in_memory( + &anchor, + Some(VisualSection { + color: "red".to_owned(), + name: None, + }) + ) + .is_err() + ); + assert!( + config + .set_visual_section_in_memory( + &anchor, + Some(VisualSection { + color: "#7dcfff".to_owned(), + name: Some("bad\nname".to_owned()), + }) + ) + .is_err() + ); + } + #[test] fn concurrent_archive_mutations_retain_both_memberships() { let directory = tempdir().unwrap(); @@ -479,6 +590,7 @@ mod tests { colors: BTreeMap::from([("colored".to_owned(), "#7aa2f7".to_owned())]), archived: BTreeSet::from(["gone".to_owned(), "kept".to_owned(), "restored".to_owned()]), stack_names: BTreeMap::from([("named".to_owned(), "Keep me".to_owned())]), + visual_sections: BTreeMap::new(), }; fallback.save(directory.path()).unwrap(); let mut mutation = ConfigMutation::default(); diff --git a/src/events.rs b/src/events.rs index 221fb88..9806afc 100644 --- a/src/events.rs +++ b/src/events.rs @@ -46,6 +46,11 @@ pub enum Key { Enter, Escape, Backspace, + Delete, + Left, + Right, + Home, + End, Character(char), Quit, Ignored, @@ -78,12 +83,11 @@ impl Key { KeyCode::Enter => Self::Enter, KeyCode::Esc => Self::Escape, KeyCode::Backspace => Self::Backspace, - KeyCode::Char('k') => Self::Up, - KeyCode::Char('j') => Self::Down, - KeyCode::Char('K') => Self::StackUp, - KeyCode::Char('J') => Self::StackDown, - KeyCode::Char('g') => Self::SectionUp, - KeyCode::Char('G') => Self::SectionDown, + KeyCode::Delete => Self::Delete, + KeyCode::Left => Self::Left, + KeyCode::Right => Self::Right, + KeyCode::Home => Self::Home, + KeyCode::End => Self::End, KeyCode::Char(character) => Self::Character(character), _ => Self::Ignored, } diff --git a/src/integration_tests/archive_workflow.rs b/src/integration_tests/archive_workflow.rs index bc20ce6..4a1181b 100644 --- a/src/integration_tests/archive_workflow.rs +++ b/src/integration_tests/archive_workflow.rs @@ -2,7 +2,7 @@ use super::common; use std::sync::Arc; -use crate::app::{Action, App, Overlay}; +use crate::app::{Action, App, ConfigTarget, Overlay}; use crate::config::ArchiveMutation; use crate::events::Key; use crate::model::topology::{ArchiveMode, ProjectionEntry}; @@ -50,6 +50,62 @@ fn visible(app: &App) -> Vec<&str> { .collect() } +#[test] +fn filtered_floating_section_label_can_recolor_and_repairs_when_anchor_is_pruned() { + let mut app = App::default(); + app.apply_snapshot(snapshot()); + app.selected = Some(BranchId::new("alpha")); + assert!(matches!( + app.handle_key(Key::Character('i')), + Action::PersistConfig(_) + )); + app.handle_key(Key::Character('n')); + for character in "Alpha feature".chars() { + app.handle_key(Key::Character(character)); + } + assert!(matches!( + app.handle_key(Key::Enter), + Action::PersistConfig(_) + )); + app.selected_label = None; + app.selected = Some(BranchId::new("alpha")); + assert!(matches!( + app.handle_key(Key::Character('x')), + Action::PersistConfig(_) + )); + app.selected_label = Some(ConfigTarget::VisualSection(BranchId::new("alpha"))); + app.selected = Some(BranchId::new("alpha")); + assert!( + !app.projection + .branch_to_visual + .contains_key(&BranchId::new("alpha")) + ); + assert!(app.projection.entries.iter().any(|entry| matches!(entry, + ProjectionEntry::VisualSectionLabel(label) if label.anchor == BranchId::new("alpha")))); + let Action::PersistConfig(recolor) = app.handle_key(Key::Character('c')) else { + panic!("floating label should remain a valid color target"); + }; + assert!( + recolor + .mutation + .visual_section_updates + .contains_key(&BranchId::new("alpha")) + ); + + let mut pruned = (*common::snapshot(vec![common::branch("main", None, "main", true)])).clone(); + pruned.generation = 2; + app.apply_snapshot(Arc::new(pruned)); + assert!(app.config.visual_section(&BranchId::new("alpha")).is_none()); + assert!(app.selected_label.is_none()); + let cleanup = app + .take_config_write_request() + .expect("pruning persists cleanup"); + assert_eq!( + cleanup.mutation.visual_section_updates[&BranchId::new("alpha")], + None + ); +} + #[test] fn x_archives_immediately_and_a_restores_active_selection_and_scroll() { let mut app = App::default(); diff --git a/src/integration_tests/common.rs b/src/integration_tests/common.rs index 8e173a1..4e3a79c 100644 --- a/src/integration_tests/common.rs +++ b/src/integration_tests/common.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -73,6 +74,7 @@ pub fn snapshot(branches: Vec) -> Arc { graphite_children: Arc::from([]), branches: Arc::from(branches), branch_index, + stack_diffs: Arc::new(HashMap::new()), state: RepositoryState::Ready, graphite_status: Arc::from("fixture"), stale_error: None, diff --git a/src/integration_tests/github_enrichment.rs b/src/integration_tests/github_enrichment.rs index 8e81e2b..25fbaf3 100644 --- a/src/integration_tests/github_enrichment.rs +++ b/src/integration_tests/github_enrichment.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::adapters::github::{GitHubError, PrMatch, parse_json}; use crate::app::App; -use crate::model::{BranchId, PullRequest}; +use crate::model::{BranchId, PullRequest, PullRequestStatus}; #[test] fn parses_batched_pr_json_and_ignores_unverifiable_entries() { @@ -12,6 +12,7 @@ fn parses_batched_pr_json_and_ignores_unverifiable_entries() { assert_eq!(matches.len(), 1); assert_eq!(matches[0].branch, BranchId::new("feature/stack-map")); assert_eq!(matches[0].pull_request.number, 42); + assert_eq!(matches[0].pull_request.status, PullRequestStatus::Approved); } #[test] @@ -31,6 +32,7 @@ fn delayed_results_match_current_branch_by_id_and_oid() { number: 42, title: Arc::from("stale"), url: Arc::from("https://example.invalid/42"), + status: PullRequestStatus::Open, }, }; app.apply_prs(vec![result.clone()]); @@ -64,3 +66,19 @@ fn malformed_provider_response_has_a_typed_failure() { let error = parse_json(b"not json").unwrap_err(); assert!(matches!(error, GitHubError::Malformed(_))); } + +#[test] +fn parses_merged_closed_and_open_pull_request_states() { + let matches = parse_json( + br#"[ + {"number":1,"title":"Merged","url":"u","headRefName":"merged","headRefOid":"a","state":"MERGED","reviewDecision":"APPROVED"}, + {"number":2,"title":"Closed","url":"u","headRefName":"closed","headRefOid":"b","state":"CLOSED","reviewDecision":"APPROVED"}, + {"number":3,"title":"Open","url":"u","headRefName":"open","headRefOid":"c","state":"OPEN","reviewDecision":null} + ]"#, + ) + .unwrap(); + + assert_eq!(matches[0].pull_request.status, PullRequestStatus::Merged); + assert_eq!(matches[1].pull_request.status, PullRequestStatus::Closed); + assert_eq!(matches[2].pull_request.status, PullRequestStatus::Open); +} diff --git a/src/integration_tests/navigation_checkout.rs b/src/integration_tests/navigation_checkout.rs index f054469..5ac7fee 100644 --- a/src/integration_tests/navigation_checkout.rs +++ b/src/integration_tests/navigation_checkout.rs @@ -8,7 +8,7 @@ use crate::adapters::git::GitAdapter; use crate::app::{ Action, App, LanePitch, MutationState, Overlay, ReconciliationOperation, ViewScope, }; -use crate::config::{ArchiveMutation, Config, ConfigMutation, config_path}; +use crate::config::{ArchiveMutation, Config, ConfigMutation, VisualSection, config_path}; use crate::events::{Input, Key}; use crate::model::BranchId; use crate::model::topology::{OrderMode, ProjectionEntry}; @@ -175,6 +175,7 @@ fn color_picker_previews_rolls_back_and_commits_one_write() { Some(&Some(Arc::from("#7aa2f7"))) ); assert_eq!(app.overlay, Overlay::None); + assert_eq!(app.handle_key(Key::Enter), Action::None); assert_eq!( app.handle_key(Key::Enter), Action::Checkout(BranchId::new("alpha")) @@ -242,8 +243,11 @@ fn stack_name_editor_prefills_saves_clears_cancels_and_refuses_trunks() { })); app.handle_key(Key::Character('n')); + assert_eq!(app.overlay, Overlay::None); + assert!(app.message.as_deref().unwrap().contains("Enter")); + app.handle_key(Key::Enter); let Overlay::StackNameEditor(editor) = &app.overlay else { - panic!("name editor should reopen"); + panic!("Enter on the selected label should reopen the editor"); }; assert_eq!(editor.draft, "Release train"); app.handle_key(Key::Escape); @@ -252,7 +256,7 @@ fn stack_name_editor_prefills_saves_clears_cancels_and_refuses_trunks() { Some("Release train") ); - app.handle_key(Key::Character('n')); + app.handle_key(Key::Enter); for _ in 0.."Release train".chars().count() { app.handle_key(Key::Backspace); } @@ -274,6 +278,131 @@ fn stack_name_editor_prefills_saves_clears_cancels_and_refuses_trunks() { assert!(app.message.as_deref().unwrap().contains("trunk")); } +#[test] +fn stack_name_editor_supports_cursor_insertion_and_forward_delete() { + let mut app = App::default(); + app.apply_snapshot(view_snapshot()); + app.selected = Some(BranchId::new("alpha")); + app.handle_key(Key::Character('n')); + app.handle_key(Key::Character('a')); + app.handle_key(Key::Character('c')); + app.handle_key(Key::Left); + app.handle_key(Key::Character('b')); + let Overlay::StackNameEditor(editor) = &app.overlay else { + panic!("name editor"); + }; + assert_eq!(editor.draft, "abc"); + assert_eq!(editor.cursor, 2); + + app.handle_key(Key::Home); + app.handle_key(Key::Right); + app.handle_key(Key::Delete); + app.handle_key(Key::End); + app.handle_key(Key::Backspace); + let Overlay::StackNameEditor(editor) = &app.overlay else { + panic!("name editor"); + }; + assert_eq!(editor.draft, "a"); + assert_eq!(editor.cursor, 1); +} + +#[test] +fn visual_section_toggle_names_inline_and_keeps_git_actions_off_labels() { + let mut app = App::default(); + app.apply_snapshot(view_snapshot()); + app.selected = Some(BranchId::new("alpha")); + + let Action::PersistConfig(create) = app.handle_key(Key::Character('i')) else { + panic!("creating a visual section should persist"); + }; + let section = create.mutation.visual_section_updates[&BranchId::new("alpha")] + .as_ref() + .unwrap(); + assert!(section.name.is_none()); + assert!( + app.projection + .row_for(&BranchId::new("alpha")) + .unwrap() + .manual_depth + > 0 + ); + + app.handle_key(Key::Character('n')); + for character in "jkgGJK section".chars() { + app.handle_key(Key::Character(character)); + } + assert!(matches!(app.handle_key(Key::Quit), Action::None)); + let Action::PersistConfig(named) = app.handle_key(Key::Enter) else { + panic!("section name should persist"); + }; + assert_eq!( + named.mutation.visual_section_updates[&BranchId::new("alpha")] + .as_ref() + .unwrap() + .name + .as_deref(), + Some("jkgGJK section") + ); + assert!( + app.selected_branch().is_none(), + "a selected label must not masquerade as a branch" + ); + assert!(matches!(app.handle_key(Key::Enter), Action::None)); + app.handle_key(Key::Escape); + + app.selected_label = None; + app.selected = Some(BranchId::new("alpha")); + let Action::PersistConfig(remove) = app.handle_key(Key::Character('i')) else { + panic!("removing a visual section should persist"); + }; + assert_eq!( + remove.mutation.visual_section_updates[&BranchId::new("alpha")], + None + ); +} + +#[test] +fn section_recolor_avoids_effective_neighbors_after_legacy_color_conflicts() { + let mut app = App::default(); + app.apply_snapshot(view_snapshot()); + for anchor in ["alpha", "alpha-tip"] { + app.config + .set_visual_section_in_memory( + &BranchId::new(anchor), + Some(VisualSection { + color: "#7aa2f7".into(), + name: None, + }), + ) + .unwrap(); + } + app.handle_key(Key::Character('t')); + let lower_effective = app + .projection + .row_for(&BranchId::new("alpha")) + .unwrap() + .visual_color + .clone() + .unwrap(); + let upper_effective = app + .projection + .row_for(&BranchId::new("alpha-tip")) + .unwrap() + .visual_color + .clone() + .unwrap(); + assert_ne!(lower_effective, upper_effective); + + app.selected = Some(BranchId::new("alpha-tip")); + let Action::PersistConfig(request) = app.handle_key(Key::Character('c')) else { + panic!("recolor should remain available after conflict resolution"); + }; + let saved = request.mutation.visual_section_updates[&BranchId::new("alpha-tip")] + .as_ref() + .unwrap(); + assert_ne!(saved.color, lower_effective.as_ref()); +} + #[test] fn geometric_stack_jumps_use_visible_heads() { let mut app = App::default(); @@ -374,6 +503,59 @@ fn stack_keys_jump_true_stacks_but_move_ten_rows_from_trunks_and_one_offs() { assert_eq!(app.selected, Some(expected_other_stack)); } +#[test] +fn stack_down_from_lowest_stack_lands_on_its_trunk() { + let mut app = App::default(); + app.apply_snapshot(view_snapshot()); + let lowest_stack = app + .projection + .stack_heads + .iter() + .filter(|head| app.projection.is_true_stack(&head.branch)) + .max_by_key(|head| head.visual_row) + .unwrap() + .branch + .clone(); + app.selected = Some(lowest_stack); + + app.handle_key(Key::StackDown); + + assert_eq!(app.selected, Some(BranchId::new("main"))); +} + +#[test] +fn stack_down_to_sticky_trunk_scrolls_focused_stacks_to_the_bottom() { + let mut app = App::default(); + app.apply_snapshot(view_snapshot()); + app.selected = Some(BranchId::new("alpha")); + app.handle_key(Key::Character('H')); + app.set_viewport_height(3); + + let sticky = app + .sticky_visual_row() + .expect("focused trunk should be sticky"); + let start = app.focused_section_bounds().expect("focused section").0; + let lowest_stack = app + .projection + .stack_heads + .iter() + .filter(|head| app.projection.is_true_stack(&head.branch)) + .max_by_key(|head| head.visual_row) + .expect("true stack") + .branch + .clone(); + app.selected = Some(lowest_stack); + app.scroll = start; + + app.handle_key(Key::StackDown); + + assert_eq!(app.selected, Some(BranchId::new("main"))); + assert_eq!( + app.scroll, + sticky.saturating_sub(app.viewport_height).max(start) + ); +} + #[test] fn section_keys_jump_to_current_section_edges_without_crossing_sections() { let mut app = App::default(); @@ -573,6 +755,11 @@ fn enter_emits_one_checkout_while_checkout_is_running() { common::branch("feature", None, "feature", false), ])); app.selected = Some(BranchId::new("feature")); + assert_eq!(app.handle_key(Key::Enter), Action::None); + assert!(matches!( + app.mutation, + MutationState::ConfirmingCheckout(ref target) if target == &BranchId::new("feature") + )); assert_eq!( app.handle_key(Key::Enter), Action::Checkout(BranchId::new("feature")) @@ -580,6 +767,25 @@ fn enter_emits_one_checkout_while_checkout_is_running() { assert_eq!(app.handle_key(Key::Enter), Action::None); } +#[test] +fn checkout_confirmation_cancels_on_escape_or_navigation() { + let mut app = App::default(); + app.apply_snapshot(common::snapshot(vec![ + common::branch("main", None, "main", true), + common::branch("feature", None, "feature", false), + ])); + app.selected = Some(BranchId::new("feature")); + + assert_eq!(app.handle_key(Key::Enter), Action::None); + assert_eq!(app.handle_key(Key::Escape), Action::None); + assert!(matches!(app.mutation, MutationState::Idle)); + + assert_eq!(app.handle_key(Key::Enter), Action::None); + assert_eq!(app.handle_key(Key::Up), Action::None); + assert!(matches!(app.mutation, MutationState::Idle)); + assert_eq!(app.selected, Some(BranchId::new("main"))); +} + #[test] fn checkout_is_disabled_for_current_branch_with_explanation() { let mut app = App::default(); @@ -816,6 +1022,7 @@ fn checkout_reconciliation_ignores_a_pre_mutation_refresh_that_arrives_late() { let mut app = App::with_mutation_timing(Duration::from_secs(10), Duration::from_secs(2)); app.apply_snapshot(with_generation(&snapshot, 5)); app.selected = Some(BranchId::new("feature")); + assert_eq!(app.handle_key(Key::Enter), Action::None); assert!(matches!(app.handle_key(Key::Enter), Action::Checkout(_))); app.finish_checkout_at(Ok(()), 42, now); app.apply_structural_snapshot_at(with_generation(&snapshot, 6), 41, now); @@ -854,6 +1061,7 @@ fn causal_checkout_mismatch_unlocks_with_targeted_refresh_guidance() { let mut app = App::with_mutation_timing(Duration::from_secs(10), Duration::from_secs(5)); app.apply_snapshot(with_generation(&snapshot, 5)); app.selected = Some(BranchId::new("feature")); + assert_eq!(app.handle_key(Key::Enter), Action::None); assert_eq!( app.handle_key(Key::Enter), Action::Checkout(BranchId::new("feature")) @@ -880,6 +1088,7 @@ fn checkout_reconciliation_deadline_unlocks_and_expires_deterministically() { let mut app = App::with_mutation_timing(Duration::from_secs(3), Duration::from_secs(2)); app.apply_snapshot(snapshot); app.selected = Some(BranchId::new("feature")); + assert_eq!(app.handle_key(Key::Enter), Action::None); assert!(matches!(app.handle_key(Key::Enter), Action::Checkout(_))); app.finish_checkout_at(Ok(()), 9, now); app.mark_stale(Arc::from("refresh failed")); diff --git a/src/integration_tests/refresh_pipeline.rs b/src/integration_tests/refresh_pipeline.rs index c06d33c..864e1ac 100644 --- a/src/integration_tests/refresh_pipeline.rs +++ b/src/integration_tests/refresh_pipeline.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use std::{fs, path::PathBuf}; use crate::adapters::git::GitAdapter; -use crate::app::App; +use crate::app::{Action, App}; use crate::config::config_path; +use crate::events::Key; use crate::model::BranchId; use crate::model::RemoteRefEvidence; use crate::refresh::builder::SnapshotBuilder; @@ -43,6 +44,66 @@ fn reducer_does_not_retain_obsolete_snapshot_generations() { ); } +#[test] +fn pending_visual_section_mutations_coalesce_and_stale_completion_cannot_restore_removed_data() { + let mut app = App::default(); + let mut root = common::branch("feature", None, "feature", false); + root.trunk = Some(BranchId::new("main")); + root.graphite = crate::model::GraphiteProvenance::Tracked; + let mut main = common::branch("main", None, "main", true); + main.trunk = Some(BranchId::new("main")); + main.graphite = crate::model::GraphiteProvenance::Tracked; + let mut snapshot = (*common::snapshot(vec![main, root])).clone(); + snapshot.configured_trunks = Arc::from([BranchId::new("main")]); + snapshot.trunks = snapshot.configured_trunks.clone(); + snapshot.graphite_children = + Arc::from([(BranchId::new("main"), Arc::from([BranchId::new("feature")]))]); + app.apply_snapshot(Arc::new(snapshot.clone())); + app.selected = Some(BranchId::new("feature")); + + let Action::PersistConfig(created) = app.handle_key(Key::Character('i')) else { + panic!("create"); + }; + let Action::PersistConfig(recolored) = app.handle_key(Key::Character('c')) else { + panic!("recolor"); + }; + app.handle_key(Key::Character('n')); + for character in "Named".chars() { + app.handle_key(Key::Character(character)); + } + let Action::PersistConfig(named) = app.handle_key(Key::Enter) else { + panic!("name"); + }; + app.selected_label = None; + app.selected = Some(BranchId::new("feature")); + let Action::PersistConfig(removed) = app.handle_key(Key::Character('i')) else { + panic!("remove"); + }; + assert_eq!( + removed.mutation.visual_section_updates[&BranchId::new("feature")], + None + ); + + app.finish_config_persistence(created.sequence, Ok(())); + app.finish_config_persistence(recolored.sequence, Ok(())); + app.finish_config_persistence(named.sequence, Ok(())); + let pending = app + .prepare_pending_config_persistence(removed.clone()) + .expect("latest removal remains pending after older completions"); + assert_eq!( + pending.mutation.visual_section_updates[&BranchId::new("feature")], + None + ); + + snapshot.generation = 2; + app.apply_snapshot(Arc::new(snapshot)); + assert!( + app.config + .visual_section(&BranchId::new("feature")) + .is_none() + ); +} + #[test] fn one_hundred_refresh_generations_keep_only_latest_snapshot() { let mut app = App::default(); @@ -108,6 +169,90 @@ fn delayed_diff_enrichment_does_not_clear_structural_failure() { assert_eq!(app.refresh_error.as_deref(), Some("inventory failed")); } +#[test] +fn structural_tip_change_moves_selection_to_changed_branch() { + let initial = common::snapshot(vec![ + common::branch("main", None, "main", true), + common::branch("agent-work", None, "agent-work", false), + common::branch("other", None, "other", false), + ]); + let mut app = App::default(); + app.apply_snapshot(initial.clone()); + app.selected = Some(BranchId::new("other")); + + let mut changed = (*initial).clone(); + changed.generation = 2; + let branch = Arc::make_mut(&mut changed.branches) + .iter_mut() + .find(|branch| branch.id == BranchId::new("agent-work")) + .unwrap(); + branch.oid = Arc::from("oid-agent-work-updated"); + branch.committed_at += 10; + app.apply_snapshot(Arc::new(changed)); + + assert_eq!(app.selected, Some(BranchId::new("agent-work"))); + assert!(app.selected_label.is_none()); +} + +#[test] +fn newest_selectable_tip_change_wins_and_hidden_changes_do_not_move_selection() { + let initial = common::snapshot(vec![ + common::branch("main", None, "main", true), + common::branch("older", None, "older", false), + common::branch("newer", None, "newer", false), + ]); + let mut app = App::default(); + app.apply_snapshot(initial.clone()); + app.selected = Some(BranchId::new("main")); + + let mut changed = (*initial).clone(); + changed.generation = 2; + for branch in Arc::make_mut(&mut changed.branches) { + if branch.id == BranchId::new("older") { + branch.oid = Arc::from("oid-older-updated"); + branch.committed_at += 10; + } else if branch.id == BranchId::new("newer") { + branch.oid = Arc::from("oid-newer-updated"); + branch.committed_at += 20; + } + } + app.apply_snapshot(Arc::new(changed.clone())); + assert_eq!(app.selected, Some(BranchId::new("newer"))); + + app.filter = "main".into(); + app.selected = Some(BranchId::new("main")); + changed.generation = 3; + Arc::make_mut(&mut changed.branches) + .iter_mut() + .find(|branch| branch.id == BranchId::new("older")) + .unwrap() + .oid = Arc::from("oid-older-updated-again"); + app.apply_snapshot(Arc::new(changed)); + assert_eq!(app.selected, Some(BranchId::new("main"))); +} + +#[test] +fn enrichment_tip_change_does_not_move_selection() { + let initial = common::snapshot(vec![ + common::branch("main", None, "main", true), + common::branch("agent-work", None, "agent-work", false), + ]); + let mut app = App::default(); + app.apply_snapshot(initial.clone()); + app.selected = Some(BranchId::new("main")); + + let mut enriched = (*initial).clone(); + enriched.generation = 2; + Arc::make_mut(&mut enriched.branches) + .iter_mut() + .find(|branch| branch.id == BranchId::new("agent-work")) + .unwrap() + .oid = Arc::from("oid-agent-work-updated"); + app.apply_enriched_snapshot(Arc::new(enriched)); + + assert_eq!(app.selected, Some(BranchId::new("main"))); +} + #[test] fn invalid_config_keeps_last_valid_in_memory_values() { let directory = tempfile::tempdir().unwrap(); @@ -130,14 +275,17 @@ fn invalid_config_keeps_last_valid_in_memory_values() { } #[test] -fn active_view_never_requests_upstream_and_archive_targets_only_hidden_rows() { +fn active_and_archive_views_request_remote_evidence_for_visible_rows() { let mut app = App::default(); app.apply_snapshot(common::snapshot(vec![ common::branch("main", None, "main", true), common::branch("hidden", None, "hidden", false), common::branch("visible", None, "visible", false), ])); - assert!(app.take_upstream_command().is_none()); + let Some(UpstreamCommand::Request(active)) = app.take_upstream_command() else { + panic!("active view should request visible remote evidence"); + }; + assert_eq!(active.targets.len(), 3); app.config .set_archived_in_memory(&BranchId::new("hidden"), true); @@ -159,7 +307,10 @@ fn active_view_never_requests_upstream_and_archive_targets_only_hidden_rows() { assert!(app.take_upstream_command().is_none()); app.handle_key(crate::events::Key::Character('a')); - assert_eq!(app.take_upstream_command(), Some(UpstreamCommand::Cancel)); + let Some(UpstreamCommand::Request(active_again)) = app.take_upstream_command() else { + panic!("returning to Active should request visible remote evidence"); + }; + assert_eq!(active_again.targets.len(), 2); assert!(app.take_upstream_command().is_none()); } diff --git a/src/integration_tests/terminal_interaction.rs b/src/integration_tests/terminal_interaction.rs index d3ae783..e347233 100644 --- a/src/integration_tests/terminal_interaction.rs +++ b/src/integration_tests/terminal_interaction.rs @@ -31,13 +31,29 @@ fn key_events_preserve_phase_and_normalize_portable_navigation_fallbacks() { )), Some(Input::repeat(Key::SectionDown)) ); + assert_eq!( + Input::from_event(event( + KeyCode::Left, + KeyModifiers::NONE, + KeyEventKind::Press + )), + Some(Input::press(Key::Left)) + ); + assert_eq!( + Input::from_event(event( + KeyCode::Delete, + KeyModifiers::NONE, + KeyEventKind::Press + )), + Some(Input::press(Key::Delete)) + ); assert_eq!( Input::from_event(event( KeyCode::Char('K'), KeyModifiers::SHIFT, KeyEventKind::Press )), - Some(Input::press(Key::StackUp)) + Some(Input::press(Key::Character('K'))) ); assert_eq!( Input::from_event(event( @@ -45,7 +61,7 @@ fn key_events_preserve_phase_and_normalize_portable_navigation_fallbacks() { KeyModifiers::NONE, KeyEventKind::Press )), - Some(Input::press(Key::StackDown)) + Some(Input::press(Key::Character('J'))) ); assert_eq!( Input::from_event(event( @@ -53,7 +69,7 @@ fn key_events_preserve_phase_and_normalize_portable_navigation_fallbacks() { KeyModifiers::NONE, KeyEventKind::Press )), - Some(Input::press(Key::SectionUp)) + Some(Input::press(Key::Character('g'))) ); assert_eq!( Input::from_event(event( @@ -61,7 +77,7 @@ fn key_events_preserve_phase_and_normalize_portable_navigation_fallbacks() { KeyModifiers::SHIFT, KeyEventKind::Press )), - Some(Input::press(Key::SectionDown)) + Some(Input::press(Key::Character('G'))) ); } diff --git a/src/integration_tests/topology_layout.rs b/src/integration_tests/topology_layout.rs index 173b727..70acf74 100644 --- a/src/integration_tests/topology_layout.rs +++ b/src/integration_tests/topology_layout.rs @@ -84,7 +84,7 @@ fn linear_stack_is_bottom_up_with_trunk_at_section_bottom() { } #[test] -fn stack_name_is_a_nonselectable_row_immediately_above_the_stack_head() { +fn stack_name_has_a_dedicated_spacer_before_the_stack_head() { let snapshot = fork_snapshot(); let projection = TopologyIndex::build(&snapshot).project(&ProjectionOptions { stack_names: HashMap::from([(BranchId::new("1"), Arc::::from("Primary work"))]), @@ -95,12 +95,16 @@ fn stack_name_is_a_nonselectable_row_immediately_above_the_stack_head() { .iter() .find(|head| head.stack_id == BranchId::new("1")) .expect("named stack head"); - let label_row = head.visual_row.checked_sub(1).expect("label before head"); + let label_row = head.visual_row.checked_sub(2).expect("label before spacer"); assert!(matches!( &projection.entries[label_row], ProjectionEntry::StackLabel(label) if label.stack_id == BranchId::new("1") && label.text.as_ref() == "Primary work" )); + assert!(matches!( + &projection.entries[label_row + 1], + ProjectionEntry::Divider(DividerRow::Spacer { .. }) + )); assert_eq!( projection.selectable.len(), projection @@ -109,6 +113,154 @@ fn stack_name_is_a_nonselectable_row_immediately_above_the_stack_head() { .filter(|entry| matches!(entry, ProjectionEntry::Branch(_))) .count() ); + assert!(projection.navigation.iter().any(|target| matches!( + target, + crate::model::topology::SelectionTarget::StackLabel(stack) + if stack == &BranchId::new("1") + ))); +} + +#[test] +fn named_stack_and_first_section_project_as_title_spacer_section_branch() { + let projection = TopologyIndex::build(&fork_snapshot()).project(&ProjectionOptions { + separators: false, + stack_names: HashMap::from([(BranchId::new("1"), Arc::::from("Primary work"))]), + visual_sections: HashMap::from([( + BranchId::new("1"), + crate::model::topology::VisualSectionSpec { + color: Arc::from("#7aa2f7"), + name: Some(Arc::from("Foundation")), + }, + )]), + ..ProjectionOptions::default() + }); + let label = projection + .entries + .iter() + .position(|entry| matches!(entry, ProjectionEntry::StackLabel(_))) + .unwrap(); + + assert!(matches!( + projection.entries[label + 1], + ProjectionEntry::Divider(DividerRow::Spacer { .. }) + )); + assert!(matches!( + projection.entries[label + 2], + ProjectionEntry::VisualSectionLabel(_) + )); + assert!(matches!( + projection.entries[label + 3], + ProjectionEntry::Branch(_) + )); + assert_eq!( + projection + .navigation + .iter() + .filter(|target| matches!( + target, + crate::model::topology::SelectionTarget::StackLabel(_) + | crate::model::topology::SelectionTarget::VisualSectionLabel(_) + )) + .count(), + 2 + ); +} + +#[test] +fn visual_sections_accumulate_text_depth_without_moving_topology_lanes() { + let mut snapshot = (*common::snapshot(vec![ + tracked("main", None, "main", "main", 1), + tracked("root", None, "root", "main", 2), + tracked("middle", Some("root"), "root", "main", 3), + tracked("tip", Some("middle"), "root", "main", 4), + ])) + .clone(); + snapshot.graphite_children = Arc::from([ + (BranchId::new("main"), Arc::from([BranchId::new("root")])), + (BranchId::new("root"), Arc::from([BranchId::new("middle")])), + (BranchId::new("middle"), Arc::from([BranchId::new("tip")])), + ]); + let snapshot = Arc::new(snapshot); + let topology = TopologyIndex::build(&snapshot); + let baseline = topology.project(&ProjectionOptions::default()); + let projection = topology.project(&ProjectionOptions { + visual_sections: HashMap::from([ + ( + BranchId::new("root"), + crate::model::topology::VisualSectionSpec { + color: Arc::from("#7aa2f7"), + name: Some(Arc::from("Foundation")), + }, + ), + ( + BranchId::new("middle"), + crate::model::topology::VisualSectionSpec { + color: Arc::from("#7aa2f7"), + name: Some(Arc::from("Follow-up")), + }, + ), + ]), + ..ProjectionOptions::default() + }); + + for branch in ["root", "middle", "tip", "main"] { + assert_eq!( + projection.row_for(&BranchId::new(branch)).unwrap().lane, + baseline.row_for(&BranchId::new(branch)).unwrap().lane + ); + } + assert_eq!( + projection + .row_for(&BranchId::new("root")) + .unwrap() + .manual_depth, + 1 + ); + assert_ne!( + projection + .row_for(&BranchId::new("root")) + .unwrap() + .visual_color, + projection + .row_for(&BranchId::new("middle")) + .unwrap() + .visual_color, + "conflicting persisted colors resolve visibly without rewriting config" + ); + let middle_effective = projection + .row_for(&BranchId::new("middle")) + .unwrap() + .visual_color + .clone() + .unwrap(); + assert!(projection.entries.iter().any(|entry| matches!(entry, + ProjectionEntry::VisualSectionDivider(divider) + if divider.anchor == BranchId::new("middle") && divider.color == middle_effective))); + assert_eq!( + projection + .row_for(&BranchId::new("middle")) + .unwrap() + .manual_depth, + 2 + ); + assert_eq!( + projection + .row_for(&BranchId::new("tip")) + .unwrap() + .manual_depth, + 2 + ); + assert_eq!( + projection + .navigation + .iter() + .filter(|target| matches!( + target, + crate::model::topology::SelectionTarget::VisualSectionLabel(_) + )) + .count(), + 2 + ); } #[test] @@ -145,6 +297,41 @@ fn first_child_stays_straight_and_side_stack_attaches_to_exact_parent() { assert_eq!((connector.from_lane, connector.to_lane), (2, 1)); } +#[test] +fn stack_diff_endpoints_follow_each_real_displayed_group() { + let mut snapshot = (*fork_snapshot()).clone(); + let mut branches = snapshot.branches.to_vec(); + branches + .iter_mut() + .find(|branch| branch.id == BranchId::new("1")) + .unwrap() + .diff_parent = Some(BranchId::new("staging")); + snapshot.branch_index = crate::model::RepositorySnapshot::index_branches(&branches); + snapshot.branches = branches.into(); + + let endpoints: HashMap<_, _> = TopologyIndex::build(&snapshot) + .stack_diff_endpoints() + .into_iter() + .map(|value| (value.stack_id.clone(), value)) + .collect(); + + let primary = &endpoints[&BranchId::new("1")]; + assert_eq!(primary.bottom, BranchId::new("1")); + assert_eq!(primary.head, BranchId::new("4")); + assert_eq!( + snapshot.branch(&primary.bottom).unwrap().diff_parent, + Some(BranchId::new("staging")) + ); + + let side = &endpoints[&BranchId::new("3b")]; + assert_eq!(side.bottom, BranchId::new("3b")); + assert_eq!(side.head, BranchId::new("3c")); + assert_eq!( + snapshot.branch(&side.bottom).unwrap().diff_parent, + Some(BranchId::new("3")) + ); +} + #[test] fn roots_connect_to_exclusive_trunk_anchor_and_spacers_are_optional() { let index = TopologyIndex::build(&fork_snapshot()); diff --git a/src/integration_tests/tui_rendering.rs b/src/integration_tests/tui_rendering.rs index 73deff4..00b311e 100644 --- a/src/integration_tests/tui_rendering.rs +++ b/src/integration_tests/tui_rendering.rs @@ -4,10 +4,13 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crate::app::{App, LanePitch}; +use crate::app::{App, LanePitch, Overlay}; use crate::events::Key; -use crate::model::{BranchId, ConfiguredUpstream, DiffStat, DiffState, RemoteRefEvidence}; -use crate::ui::layout::{RenderGeometry, areas}; +use crate::model::{ + BranchId, ConfiguredUpstream, DiffStat, DiffState, PullRequest, PullRequestStatus, + RemoteRefEvidence, +}; +use crate::ui::layout::{RenderGeometry, WidthMode, areas}; use crate::ui::theme::{ TRUNK_COLOR_HEX, current_background, selected_background, stack_color, trunk_color, }; @@ -39,6 +42,170 @@ fn char_column(line: &str, needle: &str) -> Option { line.find(needle).map(|byte| line[..byte].chars().count()) } +fn visual_section_snapshot() -> Arc { + let mut main = common::branch("main", None, "main", true); + main.trunk = Some(BranchId::new("main")); + main.graphite = crate::model::GraphiteProvenance::Tracked; + let mut root = common::branch("feature-root", None, "feature-root", false); + root.trunk = Some(BranchId::new("main")); + root.graphite = crate::model::GraphiteProvenance::Tracked; + let mut tip = common::branch("feature-tip", Some("feature-root"), "feature-root", false); + tip.trunk = Some(BranchId::new("main")); + tip.graphite = crate::model::GraphiteProvenance::Tracked; + let mut snapshot = (*common::snapshot(vec![main, root, tip])).clone(); + snapshot.configured_trunks = Arc::from([BranchId::new("main")]); + snapshot.trunks = snapshot.configured_trunks.clone(); + snapshot.graphite_children = Arc::from([ + ( + BranchId::new("main"), + Arc::from([BranchId::new("feature-root")]), + ), + ( + BranchId::new("feature-root"), + Arc::from([BranchId::new("feature-tip")]), + ), + ]); + Arc::new(snapshot) +} + +fn add_named_visual_section(app: &mut App, name: &str) { + app.selected = Some(BranchId::new("feature-root")); + assert!(matches!( + app.handle_key(Key::Character('i')), + crate::app::Action::PersistConfig(_) + )); + app.handle_key(Key::Character('n')); + for character in name.chars() { + app.handle_key(Key::Character(character)); + } + assert!(matches!( + app.handle_key(Key::Enter), + crate::app::Action::PersistConfig(_) + )); + app.selected_label = None; + app.selected = Some(BranchId::new("feature-root")); +} + +fn add_named_stack(app: &mut App, name: &str) { + app.selected = Some(BranchId::new("feature-root")); + app.handle_key(Key::Character('n')); + for character in name.chars() { + app.handle_key(Key::Character(character)); + } + assert!(matches!( + app.handle_key(Key::Enter), + crate::app::Action::PersistConfig(_) + )); +} + +#[test] +fn visual_sections_indent_and_color_branch_names_without_moving_graph_columns() { + let snapshot = visual_section_snapshot(); + let mut baseline = App::default(); + baseline.apply_snapshot(snapshot.clone()); + let mut sectioned = App::default(); + sectioned.apply_snapshot(snapshot); + add_named_visual_section(&mut sectioned, "Feature area"); + let expected_color = sectioned + .config + .visual_section(&BranchId::new("feature-root")) + .unwrap() + .color + .clone(); + let render = |app: &mut App| { + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, app, UNIX_EPOCH)) + .unwrap(); + rendered_lines(&terminal) + }; + let baseline_lines = render(&mut baseline); + let section_lines = render(&mut sectioned); + for branch in ["feature-root", "feature-tip"] { + let (_, before) = line_with(&baseline_lines, branch); + let (after_row, after) = line_with(§ion_lines, branch); + assert_eq!(char_column(before, "○"), char_column(after, "○")); + assert!(char_column(after, branch) > char_column(before, branch)); + let name_x = char_column(after, branch).unwrap() as u16; + assert_eq!( + sectioned + .projection + .row_for(&BranchId::new(branch)) + .unwrap() + .visual_color + .as_deref(), + Some(expected_color.as_str()) + ); + assert_eq!(section_lines[after_row].chars().count(), 80); + assert_ne!(name_x, 0); + } + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut sectioned, UNIX_EPOCH)) + .unwrap(); + let (tip_y, tip_line) = line_with(§ion_lines, "feature-tip"); + let tip_x = char_column(tip_line, "feature-tip").unwrap(); + assert_eq!( + terminal.backend().buffer()[(tip_x as u16, tip_y as u16)].fg, + crate::ui::theme::visual_section_color(&expected_color) + ); + assert!(section_lines.iter().any(|line| line.contains("────────"))); +} + +#[test] +fn inline_section_editor_renders_cursor_once_and_footer_only_instructions() { + let mut app = App::default(); + app.apply_snapshot(visual_section_snapshot()); + app.selected = Some(BranchId::new("feature-root")); + assert!(matches!( + app.handle_key(Key::Character('i')), + crate::app::Action::PersistConfig(_) + )); + app.handle_key(Key::Character('n')); + for character in "Draft jK".chars() { + app.handle_key(Key::Character(character)); + } + assert!(matches!(app.overlay, Overlay::StackNameEditor(_))); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let lines = rendered_lines(&terminal); + let joined = lines.join("\n"); + assert!(joined.contains("Draft jK▏")); + assert_eq!( + joined.matches("Draft jK").count(), + 1, + "footer must not duplicate the draft" + ); + assert!(joined.contains("Enter save")); + assert!(app.selected_branch().is_none()); + assert!(line_with(&lines, "Draft jK").1.contains('›')); +} + +#[test] +fn forty_column_visual_sections_keep_non_color_depth_and_boundary_cues() { + let mut app = App::default(); + app.apply_snapshot(visual_section_snapshot()); + add_named_visual_section(&mut app, "Feature area"); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); + crate::ui::theme::with_no_color(|| { + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + }); + let lines = rendered_lines(&terminal); + assert!(lines.iter().all(|line| line.chars().count() == 40)); + assert!(lines.iter().any(|line| line.contains("Feature area"))); + assert!(lines.iter().any(|line| line.contains('─'))); + let (label_y, label_line) = line_with(&lines, "Feature area"); + let label_x = char_column(label_line, "Feature area").unwrap(); + assert_eq!( + terminal.backend().buffer()[(label_x as u16, label_y as u16)].fg, + Color::White + ); +} + #[test] fn narrow_renderer_contains_every_fixed_semantic_field() { let mut app = App::default(); @@ -78,7 +245,11 @@ fn additions_and_deletions_use_independent_semantic_colors() { deletions: 7, ..DiffStat::default() }); - app.apply_snapshot(common::snapshot(vec![branch])); + app.apply_snapshot(common::snapshot(vec![ + branch, + common::branch("selected", None, "selected", false), + ])); + app.selected = Some(BranchId::new("selected")); let backend = TestBackend::new(80, 8); let mut terminal = Terminal::new(backend).unwrap(); terminal @@ -99,6 +270,27 @@ fn additions_and_deletions_use_independent_semantic_colors() { assert_eq!(deletion.fg, Color::Red); } +#[test] +fn compact_diff_counts_keep_tenths_below_ten_thousand() { + let mut app = App::default(); + let mut branch = common::branch("feature/large-diff", None, "feature/large-diff", true); + branch.diff = DiffState::Ready(DiffStat { + insertions: 4_321, + deletions: 9_876, + ..DiffStat::default() + }); + app.apply_snapshot(common::snapshot(vec![branch])); + + let mut terminal = Terminal::new(TestBackend::new(80, 8)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let rendered = rendered_lines(&terminal).join("\n"); + + assert!(rendered.contains("+4.3K"), "{rendered}"); + assert!(rendered.contains("-9.8K"), "{rendered}"); +} + #[test] fn render_geometry_is_left_anchored_and_selection_independent() { let narrow = RenderGeometry::new(80, crate::ui::layout::WidthMode::Narrow, LanePitch::Auto, 4); @@ -190,14 +382,7 @@ fn trunk_uses_reserved_bold_hue_and_checked_out_marker() { accent } ); - assert_eq!( - marker.fg, - if accent == Color::Reset { - Color::Reset - } else { - Color::Black - } - ); + assert_eq!(marker.fg, Color::Black); assert!(marker.modifier.contains(Modifier::BOLD)); app.config @@ -217,7 +402,7 @@ fn trunk_uses_reserved_bold_hue_and_checked_out_marker() { } #[test] -fn selected_and_current_backgrounds_fill_rows_without_destroying_diff_colors() { +fn selected_rows_use_dark_content_and_current_rows_use_tinted_stack_fill() { let mut current = common::branch("current", None, "current", true); current.diff = DiffState::Ready(DiffStat { insertions: 12, @@ -236,6 +421,7 @@ fn selected_and_current_backgrounds_fill_rows_without_destroying_diff_colors() { let lines = rendered_lines(&terminal); let (current_y, _) = line_with(&lines, "current"); let (selected_y, _) = line_with(&lines, "selected"); + let current_accent = stack_color("test-repository", &BranchId::new("current"), &app.config); let selected_accent = stack_color("test-repository", &BranchId::new("selected"), &app.config); let selected_accent = if selected_accent == Color::Reset { selected_background() @@ -245,7 +431,7 @@ fn selected_and_current_backgrounds_fill_rows_without_destroying_diff_colors() { for x in 0..80 { assert_eq!( terminal.backend().buffer()[(x, current_y as u16)].bg, - current_background() + current_background(current_accent) ); assert_eq!( terminal.backend().buffer()[(x, selected_y as u16)].bg, @@ -261,14 +447,49 @@ fn selected_and_current_backgrounds_fill_rows_without_destroying_diff_colors() { assert!( selected_cells .clone() - .any(|cell| cell.symbol() == "+" && cell.fg == Color::Green) + .any(|cell| cell.symbol() == "+" && cell.fg == Color::Black) + ); + assert!(selected_cells.any(|cell| cell.symbol() == "-" && cell.fg == Color::Black)); + let (_, selected_line) = line_with(&lines, "selected"); + let selected_name_x = char_column(selected_line, "selected").unwrap(); + assert_eq!( + terminal.backend().buffer()[(selected_name_x as u16, selected_y as u16)].fg, + Color::Black ); - assert!(selected_cells.any(|cell| cell.symbol() == "-" && cell.fg == Color::Red)); } #[test] -fn named_stack_renders_a_white_nonselectable_label_above_its_head() { +fn pull_request_status_replaces_the_number_for_terminal_states_and_approval() { + for (status, expected) in [ + (PullRequestStatus::Approved, "Approved"), + (PullRequestStatus::Closed, "Closed"), + (PullRequestStatus::Merged, "Merged"), + ] { + let mut branch = common::branch("feature", None, "feature", false); + branch.pr = Some(PullRequest { + number: 42, + title: Arc::from("Feature"), + url: Arc::from("https://example.invalid/42"), + status, + }); + let mut app = App::default(); + app.apply_snapshot(common::snapshot(vec![branch])); + let mut terminal = Terminal::new(TestBackend::new(100, 8)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + assert!( + rendered_lines(&terminal) + .iter() + .any(|line| line.contains(expected)) + ); + } +} + +#[test] +fn named_stack_renders_a_white_label_with_a_spacer_above_its_head() { let mut app = App::default(); + app.detail_sidebar = true; app.apply_snapshot(common::snapshot(vec![ common::branch("root", None, "root", false), common::branch("tip", Some("root"), "root", false), @@ -279,6 +500,7 @@ fn named_stack_renders_a_white_nonselectable_label_above_its_head() { app.handle_key(Key::Character(character)); } app.handle_key(Key::Enter); + app.selected_label = None; let mut terminal = Terminal::new(TestBackend::new(90, 10)).unwrap(); terminal @@ -287,7 +509,7 @@ fn named_stack_renders_a_white_nonselectable_label_above_its_head() { let lines = rendered_lines(&terminal); let (label_y, label_line) = line_with(&lines, "Release train"); let (tip_y, _) = line_with(&lines, "tip"); - assert_eq!(label_y + 1, tip_y); + assert_eq!(label_y + 2, tip_y); let label_x = char_column(label_line, "Release train").unwrap() as u16; let cell = &terminal.backend().buffer()[(label_x, label_y as u16)]; assert_eq!(cell.fg, Color::White); @@ -346,6 +568,7 @@ fn footer_labels_a_as_view_archive() { #[test] fn worktree_indicator_is_fixed_and_wide_detail_shows_path() { let mut app = App::default(); + app.detail_sidebar = true; let mut current = common::branch("current", None, "current", true); current.worktree = Some("/repo".into()); let mut linked = common::branch("linked", None, "linked", false); @@ -410,9 +633,12 @@ fn stack_local_name_columns_are_stable_across_selection_and_child_lanes() { terminal .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) .unwrap(); - let body_width = crate::ui::layout::areas(ratatui::layout::Rect::new(0, 0, width, 12)) - .body - .width; + let body_width = crate::ui::layout::areas( + ratatui::layout::Rect::new(0, 0, width, 12), + app.detail_sidebar, + ) + .body + .width; let lines: Vec = (0..12) .map(|y| { (0..body_width) @@ -483,10 +709,10 @@ fn connectors_draw_exact_lane_endpoints_and_root_contact() { let mut app = App::default(); app.apply_snapshot(Arc::new(snapshot)); let terminal_area = ratatui::layout::Rect::new(0, 0, 80, 20); - let body = areas(terminal_area).body; + let body = areas(terminal_area, app.detail_sidebar).body; let geometry = RenderGeometry::new( body.width, - areas(terminal_area).mode, + areas(terminal_area, app.detail_sidebar).mode, app.lane_pitch, app.projection.lane_count, ); @@ -620,7 +846,7 @@ fn plus_minus_and_zero_change_global_geometry_without_selection_recentering() { common::branch("primary", Some("root"), "root", false), common::branch("side", Some("root"), "root", false), ])); - let area = areas(ratatui::layout::Rect::new(0, 0, 80, 12)); + let area = areas(ratatui::layout::Rect::new(0, 0, 80, 12), app.detail_sidebar); let automatic = RenderGeometry::new( area.body.width, area.mode, @@ -681,6 +907,30 @@ fn deletion_confirmation_keeps_choices_visible_at_minimum_width() { assert!(rendered.contains("[n/Esc] cancel")); } +#[test] +fn checkout_confirmation_is_a_visible_popup_at_minimum_width() { + let mut app = App::default(); + let target = "feature/a-long-but-valid-branch-name"; + app.apply_snapshot(common::snapshot(vec![ + common::branch("main", None, "main", true), + common::branch(target, None, target, false), + ])); + app.selected = Some(BranchId::new(target)); + assert_eq!(app.handle_key(Key::Enter), crate::app::Action::None); + + let backend = TestBackend::new(40, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let rendered = rendered_lines(&terminal).join("\n"); + + assert!(rendered.contains("Confirm switch")); + assert!(rendered.contains("Switch to this branch?")); + assert!(rendered.contains("feature/a-long-but-valid-branch-")); + assert!(rendered.contains("Enter switch · Esc cancel")); +} + #[test] fn too_narrow_terminal_has_explicit_state() { let mut app = App::default(); @@ -787,9 +1037,149 @@ fn forty_column_terminal_renders_branches() { app.projection.lane_count, ); assert!(geometry.time.is_none()); + assert!(geometry.remote.is_none()); assert!(geometry.pr.is_none()); } +#[test] +fn metadata_columns_leave_time_diff_and_pr_edge_gutters() { + let geometry = RenderGeometry::new(100, WidthMode::Medium, LanePitch::Auto, 2); + let time = geometry.time.expect("time column"); + let remote = geometry.remote.expect("remote column"); + let pr = geometry.pr.expect("PR column"); + + assert_eq!(geometry.diff.x, time.x + time.width + 1); + assert_eq!( + geometry.worktree.x, + geometry.diff.x + geometry.diff.width + 1 + ); + assert_eq!(remote.x, geometry.worktree.x + geometry.worktree.width); + assert_eq!(pr.x, remote.x + remote.width); + assert_eq!(pr.x + pr.width + 1, geometry.width); +} + +#[test] +fn main_page_remote_column_distinguishes_all_safety_states() { + let mut pushed = common::branch("pushed", None, "pushed", false); + pushed.configured_upstream = ConfiguredUpstream::Equal { + reference: Arc::from("origin/pushed"), + }; + let mut ahead = common::branch("ahead", None, "ahead", false); + ahead.configured_upstream = ConfiguredUpstream::Ahead { + reference: Arc::from("origin/ahead"), + ahead: 2, + }; + let mut behind = common::branch("behind", None, "behind", false); + behind.configured_upstream = ConfiguredUpstream::Behind { + reference: Arc::from("origin/behind"), + behind: 3, + }; + let mut diverged = common::branch("diverged", None, "diverged", false); + diverged.configured_upstream = ConfiguredUpstream::Diverged { + reference: Arc::from("origin/diverged"), + ahead: 4, + behind: 5, + }; + let mut gone = common::branch("gone", None, "gone", false); + gone.configured_upstream = ConfiguredUpstream::Gone { + reference: Arc::from("origin/gone"), + }; + let mut no_remote = common::branch("no-remote", None, "no-remote", false); + no_remote.remote_ref = RemoteRefEvidence::LocalOnly { + source_token: 1, + checked_at: UNIX_EPOCH, + }; + let mut unknown = common::branch("unknown", None, "unknown", false); + unknown.configured_upstream = ConfiguredUpstream::Unavailable { + reference: None, + reason: Arc::from("unreadable"), + }; + let mut app = App::default(); + app.apply_snapshot(common::snapshot(vec![ + pushed, ahead, behind, diverged, gone, no_remote, unknown, + ])); + + let mut terminal = Terminal::new(TestBackend::new(140, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let rendered = rendered_lines(&terminal).join("\n"); + for expected in [ + "✓ pushed", + "↑2 ahead", + "↓3 behind", + "↕4/5 div", + "× gone", + "○ no remote", + "? remote", + ] { + assert!( + rendered.contains(expected), + "missing {expected:?}:\n{rendered}" + ); + } +} + +#[test] +fn named_stack_renders_summary_then_a_dedicated_spacer() { + let mut snapshot = (*visual_section_snapshot()).clone(); + snapshot.stack_diffs = Arc::new(std::collections::HashMap::from([( + BranchId::new("feature-root"), + DiffState::Ready(DiffStat { + insertions: 12, + deletions: 3, + files: 2, + binary_files: 0, + }), + )])); + let mut app = App::default(); + app.apply_snapshot(Arc::new(snapshot)); + add_named_stack(&mut app, "Feature group"); + app.selected_label = Some(crate::app::ConfigTarget::Stack(BranchId::new( + "feature-root", + ))); + + let width = 100; + let backend = TestBackend::new(width, 14); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let lines = rendered_lines(&terminal); + let (title_row, title) = line_with(&lines, "Feature group"); + assert!(title.contains("2 branches")); + assert!(title.contains("+12")); + assert!(title.contains("-3")); + assert!(!lines[title_row + 1].contains("feature-tip")); + assert!(lines[title_row + 2].contains("feature-tip")); + + let geometry = RenderGeometry::new( + width, + WidthMode::Medium, + LanePitch::Auto, + app.projection.lane_count, + ); + let buffer = terminal.backend().buffer(); + let expected_background = stack_color( + "test-repository", + &BranchId::new("feature-root"), + &app.config, + ); + let expected_background = if expected_background == Color::Reset { + selected_background() + } else { + expected_background + }; + for x in geometry.diff.x..geometry.diff.x + geometry.diff.width { + if buffer[(x as u16, title_row as u16)].symbol() != " " { + assert_eq!(buffer[(x as u16, title_row as u16)].fg, Color::White); + } + assert_eq!(buffer[(x as u16, title_row as u16)].bg, expected_background); + } + let title_x = char_column(title, "Feature group").unwrap(); + assert_eq!(buffer[(title_x as u16, title_row as u16)].fg, Color::White); +} + #[test] fn relative_time_boundaries_are_deterministic() { let now = UNIX_EPOCH + Duration::from_secs(200_000); @@ -811,6 +1201,15 @@ fn wide_renderer_includes_selected_branch_detail() { )])); let backend = TestBackend::new(140, 12); let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + assert!( + !rendered_lines(&terminal) + .iter() + .any(|line| line.contains("Branch detail")) + ); + app.handle_key(crate::events::Key::Character('d')); terminal .draw(|frame| { crate::ui::render( @@ -867,6 +1266,10 @@ fn footer_keeps_controls_progress_notices_and_stale_health_independent() { app.selected = Some(BranchId::new("feature")); app.message = Some(Arc::from("ordinary message")); app.mark_stale(Arc::from("refresh failed")); + assert_eq!( + app.handle_key(crate::events::Key::Enter), + crate::app::Action::None + ); assert!(matches!( app.handle_key(crate::events::Key::Enter), crate::app::Action::Checkout(_) @@ -1083,8 +1486,7 @@ fn forty_column_archive_row_composes_worktree_divergence_and_containment() { let rendered = rendered_lines(&terminal).join("\n"); assert!(rendered.contains("useful-hidden")); assert!(rendered.contains('⎇')); - assert!(rendered.contains("↑2↓3")); - assert!(rendered.contains("r✓")); + assert!(rendered.contains("↕2/3")); assert!(rendered.contains("ARCHIVE")); assert!(rendered.contains("000000000000002c")); @@ -1094,8 +1496,7 @@ fn forty_column_archive_row_composes_worktree_divergence_and_containment() { let lines = rendered_lines(&wide); let (_, branch_line) = line_with(&lines, "useful-hidden-name"); assert!(branch_line.contains('⎇')); - assert!(branch_line.contains("up ↑2↓3")); - assert!(branch_line.contains("remote-ref ✓")); + assert!(branch_line.contains("↕2/3 div")); } #[test] @@ -1111,6 +1512,7 @@ fn wide_archive_detail_reports_canonical_upstream_source_token_time_and_no_fetch checked_at: SystemTime::UNIX_EPOCH, }; let mut app = App::default(); + app.detail_sidebar = true; app.apply_snapshot(common::snapshot(vec![ common::branch("main", None, "main", true), hidden, @@ -1140,6 +1542,7 @@ fn unavailable_archive_evidence_never_renders_as_local_only() { checked_at: SystemTime::UNIX_EPOCH, }; let mut app = App::default(); + app.detail_sidebar = true; app.apply_snapshot(common::snapshot(vec![ common::branch("main", None, "main", true), hidden, @@ -1153,7 +1556,7 @@ fn unavailable_archive_evidence_never_renders_as_local_only() { .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) .unwrap(); let rendered = rendered_lines(&terminal).join("\n"); - assert!(rendered.contains("remote ?")); + assert!(rendered.contains("? remote")); assert!(rendered.contains("unavailable")); assert!(!rendered.contains("local only")); } @@ -1304,6 +1707,43 @@ fn focused_section_reserves_one_sticky_bottom_row_with_continuation_cue() { assert!(sticky.contains("main")); } +#[test] +fn short_focused_section_bottom_aligns_immediately_above_sticky_trunk() { + let mut main = common::branch("main", None, "main", true); + main.trunk = Some(BranchId::new("main")); + main.graphite = crate::model::GraphiteProvenance::Tracked; + let mut root = common::branch("feature", None, "feature", false); + root.trunk = Some(BranchId::new("main")); + root.graphite = crate::model::GraphiteProvenance::Tracked; + let mut tip = common::branch("feature-tip", Some("feature"), "feature", false); + tip.trunk = Some(BranchId::new("main")); + tip.graphite = crate::model::GraphiteProvenance::Tracked; + let mut app = App::default(); + app.apply_snapshot(common::snapshot(vec![main, root, tip])); + app.selected = Some(BranchId::new("feature")); + app.handle_key(Key::Character('H')); + + let mut terminal = Terminal::new(TestBackend::new(80, 14)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app, UNIX_EPOCH)) + .unwrap(); + let lines = rendered_lines(&terminal); + let (trunk_row, _) = line_with(&lines, "◉ main"); + let last_feature_row = lines + .iter() + .enumerate() + .filter(|(_, line)| line.contains("feature")) + .map(|(row, _)| row) + .max() + .unwrap(); + + assert!( + trunk_row - last_feature_row <= 3, + "focused rows were not bottom-aligned: {lines:#?}" + ); + assert!(!lines[trunk_row - 1].trim().is_empty()); +} + #[test] fn footer_describes_contextual_stack_or_ten_row_navigation() { let mut app = App::default(); diff --git a/src/main.rs b/src/main.rs index 68afecb..39627be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -550,6 +550,7 @@ fn restore_terminal(writer: &mut impl Write, keyboard_enhanced: bool) -> io::Res #[cfg(test)] mod tests { + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -605,6 +606,7 @@ mod tests { )]), branch_index: RepositorySnapshot::index_branches(&branches), branches: branches.into(), + stack_diffs: Arc::new(HashMap::new()), state: RepositoryState::Ready, graphite_status: Arc::from("fixture"), stale_error: None, diff --git a/src/model/branch.rs b/src/model/branch.rs index 4b9c5b8..ddc2e9b 100644 --- a/src/model/branch.rs +++ b/src/model/branch.rs @@ -89,6 +89,26 @@ pub struct PullRequest { pub number: u64, pub title: Arc, pub url: Arc, + pub status: PullRequestStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PullRequestStatus { + Open, + Approved, + Closed, + Merged, +} + +impl PullRequestStatus { + pub fn label(self) -> &'static str { + match self { + Self::Open => "Open", + Self::Approved => "Approved", + Self::Closed => "Closed", + Self::Merged => "Merged", + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -138,6 +158,7 @@ pub struct RepositorySnapshot { pub graphite_children: Arc<[(BranchId, Arc<[BranchId]>)]>, pub branches: Arc<[Branch]>, pub branch_index: Arc>, + pub stack_diffs: Arc>, pub state: RepositoryState, pub graphite_status: Arc, pub stale_error: Option>, diff --git a/src/model/topology.rs b/src/model/topology.rs index 0dd5495..4e6427e 100644 --- a/src/model/topology.rs +++ b/src/model/topology.rs @@ -8,10 +8,22 @@ mod index; mod projection; use emission::{EmitFrame, EmitPhase}; + +type ManualSectionProjection = HashMap)>)>; +const VISUAL_SECTION_COLORS: [&str; 8] = [ + "#7aa2f7", "#bb9af7", "#7dcfff", "#ff9e64", "#9ece6a", "#f7768e", "#2ac3de", "#c0caf5", +]; pub use index::TopologyIndex; use index::{Node, ProjectionGroupState, StackGroup}; pub use projection::*; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StackDiffEndpoints { + pub stack_id: BranchId, + pub bottom: BranchId, + pub head: BranchId, +} + impl TopologyIndex { pub fn build(snapshot: &RepositorySnapshot) -> Self { Self::from_parts( @@ -182,6 +194,19 @@ impl TopologyIndex { self.stack_by_branch.get(branch) } + pub fn stack_diff_endpoints(&self) -> Vec { + self.groups + .values() + .filter_map(|group| { + Some(StackDiffEndpoints { + stack_id: group.id.clone(), + bottom: group.branches.first()?.clone(), + head: group.branches.last()?.clone(), + }) + }) + .collect() + } + pub fn trunk_for(&self, branch: &BranchId) -> Option<&BranchId> { self.nodes.get(branch)?.trunk.as_ref() } @@ -191,6 +216,32 @@ impl TopologyIndex { } pub fn project(&self, options: &ProjectionOptions) -> TopologyProjection { + // Manual section depth is derived from the complete stack, before any visibility + // projection, so filtering and archive views cannot make labels jump sideways. + let mut manual_sections = HashMap::new(); + for group in self.groups.values() { + let mut depth = 0usize; + let mut active: Option<(BranchId, Arc)> = None; + let mut lower_color = options.stack_colors.get(&group.id).cloned(); + for branch in &group.branches { + if let Some(section) = options.visual_sections.get(branch) { + depth += 1; + let color = if lower_color.as_deref() == Some(section.color.as_ref()) { + VISUAL_SECTION_COLORS + .iter() + .find(|candidate| Some(**candidate) != lower_color.as_deref()) + .copied() + .map(Arc::from) + .unwrap_or_else(|| section.color.clone()) + } else { + section.color.clone() + }; + lower_color = Some(color.clone()); + active = Some((branch.clone(), color)); + } + manual_sections.insert(branch.clone(), (depth, active.clone())); + } + } let emphasis_by_branch = self.emphasis_for_scope(&options.scope); let needle = options.filter.to_lowercase(); let filter_active = !needle.is_empty(); @@ -338,6 +389,7 @@ impl TopologyIndex { &named_visible, exact_matches.as_ref(), &group_states, + &manual_sections, &mut projection, ) else { continue; @@ -394,6 +446,9 @@ impl TopologyIndex { .is_some_and(|matches| !matches.contains(trunk_id)), is_trunk: true, emphasis, + manual_depth: 0, + visual_section: None, + visual_color: None, }, ); } @@ -445,6 +500,24 @@ impl TopologyIndex { } projection.lane_spans_by_lane[span.lane].push(span); } + for (visual_row, entry) in projection.entries.iter().enumerate() { + let target = match entry { + ProjectionEntry::Branch(row) if !row.context_only => { + Some(SelectionTarget::Branch(row.branch.clone())) + } + ProjectionEntry::StackLabel(label) => { + Some(SelectionTarget::StackLabel(label.stack_id.clone())) + } + ProjectionEntry::VisualSectionLabel(label) => { + Some(SelectionTarget::VisualSectionLabel(label.anchor.clone())) + } + _ => None, + }; + if let Some(target) = target { + projection.navigation.push(target); + projection.navigation_visual_rows.push(visual_row); + } + } projection } @@ -605,6 +678,7 @@ impl TopologyIndex { named_visible: &HashSet, exact_matches: Option<&HashSet>, group_states: &[ProjectionGroupState], + manual_sections: &ManualSectionProjection, projection: &mut TopologyProjection, ) -> Option { let root = self.groups.get(stack)?; @@ -614,6 +688,7 @@ impl TopologyIndex { } let mut started = HashSet::new(); + let mut emitted_visual_section_labels = HashSet::new(); started.insert(stack.clone()); let mut frames = vec![EmitFrame { stack: stack.clone(), @@ -786,6 +861,8 @@ impl TopologyIndex { .unwrap_or_default() }; if all_named || named_visible.contains(&branch) { + let (manual_depth, visual_section) = + manual_sections.get(&branch).cloned().unwrap_or((0, None)); if frames.last().is_some_and(|frame| frame.head.is_none()) && let Some(text) = options.stack_names.get(&stack_id).cloned() { @@ -798,6 +875,36 @@ impl TopologyIndex { stack_id: stack_id.clone(), lane, text, + branch_count: self.groups[&stack_id].branches.len(), + emphasis, + }, + )); + let spacer_row = projection.entries.len(); + frame.last_row = Some(spacer_row); + projection.entries.push(ProjectionEntry::Divider( + DividerRow::Spacer { + section: section.cloned(), + }, + )); + } + if let Some((anchor, color)) = &visual_section + && let Some(section) = options.visual_sections.get(anchor) + && section.name.is_some() + && emitted_visual_section_labels.insert(anchor.clone()) + { + let text = section.name.clone().expect("checked section name"); + let label_row = projection.entries.len(); + let frame = frames.last_mut().expect("emission frame"); + frame.first_row.get_or_insert(label_row); + frame.last_row = Some(label_row); + projection.entries.push(ProjectionEntry::VisualSectionLabel( + VisualSectionLabelRow { + anchor: anchor.clone(), + stack_id: stack_id.clone(), + lane, + manual_depth, + text, + color: color.clone(), emphasis, }, )); @@ -821,8 +928,38 @@ impl TopologyIndex { .is_some_and(|matches| !matches.contains(&branch)), is_trunk: false, emphasis, + manual_depth, + visual_section: visual_section + .as_ref() + .map(|(anchor, _)| anchor.clone()), + visual_color: visual_section + .as_ref() + .map(|(_, color)| color.clone()), }, ); + if let Some(section) = options.visual_sections.get(&branch) { + let effective_color = visual_section + .as_ref() + .filter(|(anchor, _)| anchor == &branch) + .map(|(_, color)| color.clone()) + .unwrap_or_else(|| section.color.clone()); + projection + .entries + .push(ProjectionEntry::VisualSectionDivider( + VisualSectionDividerRow { + anchor: branch.clone(), + stack_id: frames + .last() + .expect("emission frame") + .stack + .clone(), + lane, + manual_depth, + color: effective_color, + emphasis, + }, + )); + } } else { let visual_row = projection.entries.len(); let frame = frames.last_mut().expect("emission frame"); diff --git a/src/model/topology/projection.rs b/src/model/topology/projection.rs index 913290a..cceacf5 100644 --- a/src/model/topology/projection.rs +++ b/src/model/topology/projection.rs @@ -3,6 +3,12 @@ use std::sync::Arc; use super::super::BranchId; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VisualSectionSpec { + pub color: Arc, + pub name: Option>, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OrderMode { Recent, @@ -49,6 +55,8 @@ pub struct ProjectionOptions { pub archive_mode: ArchiveMode, pub archived: HashSet, pub stack_names: HashMap>, + pub stack_colors: HashMap>, + pub visual_sections: HashMap, } impl Default for ProjectionOptions { @@ -61,6 +69,8 @@ impl Default for ProjectionOptions { archive_mode: ArchiveMode::Active, archived: HashSet::new(), stack_names: HashMap::new(), + stack_colors: HashMap::new(), + visual_sections: HashMap::new(), } } } @@ -81,6 +91,9 @@ pub struct ProjectedRow { pub context_only: bool, pub is_trunk: bool, pub emphasis: Emphasis, + pub manual_depth: usize, + pub visual_section: Option, + pub visual_color: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -108,6 +121,28 @@ pub struct StackLabelRow { pub stack_id: BranchId, pub lane: usize, pub text: Arc, + pub branch_count: usize, + pub emphasis: Emphasis, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VisualSectionLabelRow { + pub anchor: BranchId, + pub stack_id: BranchId, + pub lane: usize, + pub manual_depth: usize, + pub text: Arc, + pub color: Arc, + pub emphasis: Emphasis, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VisualSectionDividerRow { + pub anchor: BranchId, + pub stack_id: BranchId, + pub lane: usize, + pub manual_depth: usize, + pub color: Arc, pub emphasis: Emphasis, } @@ -122,6 +157,8 @@ pub enum DividerRow { pub enum ProjectionEntry { Section(ProjectedSection), StackLabel(StackLabelRow), + VisualSectionLabel(VisualSectionLabelRow), + VisualSectionDivider(VisualSectionDividerRow), Branch(ProjectedRow), Divider(DividerRow), } @@ -142,6 +179,13 @@ pub struct StackAnchor { pub visual_row: usize, } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum SelectionTarget { + Branch(BranchId), + StackLabel(BranchId), + VisualSectionLabel(BranchId), +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct LaneSpan { pub stack_id: BranchId, @@ -155,6 +199,8 @@ pub struct TopologyProjection { pub entries: Vec, pub selectable: Vec, pub selectable_visual_rows: Vec, + pub navigation: Vec, + pub navigation_visual_rows: Vec, pub branch_to_visual: HashMap, pub branch_to_selectable: HashMap, pub stack_heads: Vec, diff --git a/src/refresh/builder.rs b/src/refresh/builder.rs index a6ce8ab..0188194 100644 --- a/src/refresh/builder.rs +++ b/src/refresh/builder.rs @@ -5,6 +5,7 @@ use anyhow::{Result, bail}; use crate::adapters::git::GitAdapter; use crate::adapters::graphite::read_topology; +use crate::model::topology::TopologyIndex; use crate::model::{Branch, BranchId, DiffState, RepositorySnapshot}; pub struct SnapshotBuilder { @@ -68,7 +69,7 @@ impl SnapshotBuilder { branches.sort_by(|left, right| left.id.cmp(&right.id)); let mut graphite_children: Vec<_> = graphite.child_order.into_iter().collect(); graphite_children.sort_by(|left, right| left.0.cmp(&right.0)); - let snapshot = RepositorySnapshot { + let mut snapshot = RepositorySnapshot { generation: self.generation, root: inventory.root, git_dir: inventory.git_dir, @@ -80,11 +81,19 @@ impl SnapshotBuilder { graphite_children: graphite_children.into(), branch_index: RepositorySnapshot::index_branches(&branches), branches: Arc::from(branches), + stack_diffs: Arc::new(HashMap::new()), state: inventory.state, graphite_status: graphite.status, stale_error: None, }; snapshot.validate()?; + snapshot.stack_diffs = Arc::new( + TopologyIndex::build(&snapshot) + .stack_diff_endpoints() + .into_iter() + .map(|endpoints| (endpoints.stack_id, DiffState::Loading)) + .collect(), + ); return Ok(Arc::new(snapshot)); } bail!("repository changed during three snapshot attempts") diff --git a/src/refresh/diffstats.rs b/src/refresh/diffstats.rs index 42d25d4..c2d7497 100644 --- a/src/refresh/diffstats.rs +++ b/src/refresh/diffstats.rs @@ -4,7 +4,8 @@ use std::sync::{Arc, mpsc}; use std::thread; use crate::adapters::git::GitAdapter; -use crate::model::{DiffStat, DiffState, RepositorySnapshot}; +use crate::model::topology::TopologyIndex; +use crate::model::{BranchId, DiffStat, DiffState, RepositorySnapshot}; type Key = (Arc, Arc); @@ -47,6 +48,7 @@ impl DiffCache { #[derive(Clone)] struct Task { indexes: Vec, + stack_ids: Vec, key: Key, } @@ -56,6 +58,7 @@ pub(super) fn enrich( cache: &mut DiffCache, worker_limit: usize, latest_generation: &Arc, + publish_partial: &dyn Fn(Arc), ) -> Option> { let generation = snapshot.generation; let oid_by_id: HashMap<_, _> = snapshot @@ -64,6 +67,7 @@ pub(super) fn enrich( .map(|b| (b.id.clone(), b.oid.clone())) .collect(); let mut branches = snapshot.branches.to_vec(); + let mut stack_diffs = (*snapshot.stack_diffs).clone(); let mut tasks = Vec::new(); let mut task_by_key = HashMap::new(); for (index, branch) in branches.iter_mut().enumerate() { @@ -79,21 +83,127 @@ pub(super) fn enrich( if let Some(value) = cache.get(&key) { branch.diff = DiffState::Ready(value); } else { - push_task(&mut tasks, &mut task_by_key, index, key); + push_branch_task(&mut tasks, &mut task_by_key, index, key); + } + } + for endpoints in TopologyIndex::build(&snapshot).stack_diff_endpoints() { + stack_diffs + .entry(endpoints.stack_id.clone()) + .or_insert(DiffState::Loading); + let Some(bottom) = snapshot.branch(&endpoints.bottom) else { + stack_diffs.insert( + endpoints.stack_id, + DiffState::Unavailable(Arc::from("stack bottom unavailable")), + ); + continue; + }; + let Some(parent) = bottom.diff_parent.as_ref() else { + stack_diffs.insert( + endpoints.stack_id, + DiffState::Unavailable(Arc::from("no validated stack parent")), + ); + continue; + }; + let (Some(parent_oid), Some(head_oid)) = + (oid_by_id.get(parent), oid_by_id.get(&endpoints.head)) + else { + stack_diffs.insert( + endpoints.stack_id, + DiffState::Unavailable(Arc::from("stack endpoint unavailable")), + ); + continue; + }; + let key = (parent_oid.clone(), head_oid.clone()); + if let Some(value) = cache.get(&key) { + stack_diffs.insert(endpoints.stack_id, DiffState::Ready(value)); + } else { + push_stack_task(&mut tasks, &mut task_by_key, endpoints.stack_id, key); } } if tasks.is_empty() { return (!is_obsolete(generation, latest_generation)).then(|| { Arc::new(RepositorySnapshot { branches: Arc::from(branches), + stack_diffs: Arc::new(stack_diffs), ..(*snapshot).clone() }) }); } + let aggregate_start = tasks.partition_point(|task| !task.indexes.is_empty()); + let aggregate_tasks = tasks.split_off(aggregate_start); + let branch_error = execute_tasks( + tasks, + generation, + git, + cache, + worker_limit, + latest_generation, + &mut branches, + &mut stack_diffs, + ); + if is_obsolete(generation, latest_generation) { + return None; + } + let branch_unavailable = + branch_error.unwrap_or_else(|| Arc::from("diff worker stopped unexpectedly")); + for branch in &mut branches { + if matches!(branch.diff, DiffState::Loading) { + branch.diff = DiffState::Unavailable(branch_unavailable.clone()); + } + } + if !aggregate_tasks.is_empty() { + publish_partial(Arc::new(RepositorySnapshot { + branches: Arc::from(branches.clone()), + stack_diffs: Arc::new(stack_diffs.clone()), + ..(*snapshot).clone() + })); + } + + let aggregate_error = execute_tasks( + aggregate_tasks, + generation, + git, + cache, + worker_limit, + latest_generation, + &mut branches, + &mut stack_diffs, + ); + if is_obsolete(generation, latest_generation) { + return None; + } + let aggregate_unavailable = + aggregate_error.unwrap_or_else(|| Arc::from("diff worker stopped unexpectedly")); + for state in stack_diffs.values_mut() { + if matches!(state, DiffState::Loading) { + *state = DiffState::Unavailable(aggregate_unavailable.clone()); + } + } + Some(Arc::new(RepositorySnapshot { + branches: Arc::from(branches), + stack_diffs: Arc::new(stack_diffs), + ..(*snapshot).clone() + })) +} + +#[allow(clippy::too_many_arguments)] +fn execute_tasks( + tasks: Vec, + generation: u64, + git: &GitAdapter, + cache: &mut DiffCache, + worker_limit: usize, + latest_generation: &Arc, + branches: &mut [crate::model::Branch], + stack_diffs: &mut HashMap, +) -> Option> { + if tasks.is_empty() { + return None; + } let tasks = Arc::new(tasks); let cursor = Arc::new(AtomicUsize::new(0)); - let workers = worker_limit.clamp(1, 4).min(tasks.len()); + let workers = worker_count(tasks.len(), worker_limit); let (send, receive) = mpsc::sync_channel(workers * 2); let mut handles = Vec::with_capacity(workers); let mut spawn_error = None; @@ -139,40 +249,62 @@ pub(super) fn enrich( for index in task.indexes { branches[index].diff = DiffState::Ready(stat); } + for stack_id in task.stack_ids { + stack_diffs.insert(stack_id, DiffState::Ready(stat)); + } } Err(error) => { let error: Arc = Arc::from(error); for index in task.indexes { branches[index].diff = DiffState::Unavailable(error.clone()); } + for stack_id in task.stack_ids { + stack_diffs.insert(stack_id, DiffState::Unavailable(error.clone())); + } } } } for handle in handles { let _ = handle.join(); } - let unavailable = spawn_error.unwrap_or_else(|| Arc::from("diff worker stopped unexpectedly")); - for branch in &mut branches { - if matches!(branch.diff, DiffState::Loading) { - branch.diff = DiffState::Unavailable(unavailable.clone()); - } - } - if is_obsolete(generation, latest_generation) { - return None; - } - Some(Arc::new(RepositorySnapshot { - branches: Arc::from(branches), - ..(*snapshot).clone() - })) + spawn_error } -fn push_task(tasks: &mut Vec, task_by_key: &mut HashMap, index: usize, key: Key) { +fn worker_count(task_count: usize, worker_limit: usize) -> usize { + worker_limit.clamp(1, 4).min(task_count) +} + +fn push_branch_task( + tasks: &mut Vec, + task_by_key: &mut HashMap, + index: usize, + key: Key, +) { if let Some(task) = task_by_key.get(&key).copied() { tasks[task].indexes.push(index); } else { task_by_key.insert(key.clone(), tasks.len()); tasks.push(Task { indexes: vec![index], + stack_ids: Vec::new(), + key, + }); + } +} + +fn push_stack_task( + tasks: &mut Vec, + task_by_key: &mut HashMap, + stack_id: BranchId, + key: Key, +) { + if let Some(task) = task_by_key.get(&key).copied() { + tasks[task].stack_ids.push(stack_id); + } else { + task_by_key.insert(key.clone(), tasks.len()); + tasks.push(Task { + indexes: Vec::new(), + stack_ids: vec![stack_id], key, }); } @@ -184,7 +316,13 @@ fn is_obsolete(generation: u64, latest_generation: &AtomicU64) -> bool { #[cfg(test)] mod tests { + use std::path::Path; + use std::process::Command; + use super::*; + use crate::model::{ + Branch, ConfiguredUpstream, GraphiteProvenance, RemoteRefEvidence, RepositoryState, + }; #[test] fn cache_evicts_instead_of_growing_forever() { @@ -209,16 +347,214 @@ mod tests { let key = (Arc::from("parent"), Arc::from("child")); let mut tasks = Vec::new(); let mut by_key = HashMap::new(); - push_task(&mut tasks, &mut by_key, 2, key.clone()); - push_task(&mut tasks, &mut by_key, 7, key); + push_branch_task(&mut tasks, &mut by_key, 2, key.clone()); + push_branch_task(&mut tasks, &mut by_key, 7, key); assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].indexes, [2, 7]); } + #[test] + fn branch_and_stack_targets_share_one_task() { + let key = (Arc::from("parent"), Arc::from("child")); + let mut tasks = Vec::new(); + let mut by_key = HashMap::new(); + push_branch_task(&mut tasks, &mut by_key, 2, key.clone()); + push_stack_task(&mut tasks, &mut by_key, BranchId::new("stack"), key); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].indexes, [2]); + assert_eq!(tasks[0].stack_ids, [BranchId::new("stack")]); + } + + #[test] + fn eager_summary_scheduling_is_bounded_and_branch_first_at_scale() { + for (branches, stack_size) in [(500, 1), (5_000, 10)] { + let mut tasks = Vec::new(); + let mut by_key = HashMap::new(); + for index in 0..branches { + push_branch_task( + &mut tasks, + &mut by_key, + index, + ( + Arc::from(format!("p-{index}")), + Arc::from(format!("b-{index}")), + ), + ); + } + for stack in 0..branches / stack_size { + let bottom = stack * stack_size; + let head = bottom + stack_size - 1; + push_stack_task( + &mut tasks, + &mut by_key, + BranchId::new(format!("stack-{stack}")), + ( + Arc::from(format!("p-{bottom}")), + Arc::from(format!("b-{head}")), + ), + ); + } + + let aggregate_start = tasks.partition_point(|task| !task.indexes.is_empty()); + assert_eq!(worker_count(tasks.len(), usize::MAX), 4); + assert_eq!(aggregate_start, branches); + assert_eq!( + tasks.len(), + branches + usize::from(stack_size > 1) * (branches / stack_size) + ); + assert!( + tasks[..aggregate_start] + .iter() + .all(|task| !task.indexes.is_empty()) + ); + assert!( + tasks[aggregate_start..] + .iter() + .all(|task| task.indexes.is_empty()) + ); + } + } + #[test] fn newer_structural_generation_cancels_old_work() { let latest = AtomicU64::new(4); assert!(is_obsolete(3, &latest)); assert!(!is_obsolete(4, &latest)); } + + #[test] + fn stack_summary_is_direct_base_to_tip_while_branches_remain_relative() { + let directory = tempfile::tempdir().unwrap(); + git(directory.path(), &["init", "-b", "main"]); + git(directory.path(), &["config", "user.name", "Stackmap Tests"]); + git( + directory.path(), + &["config", "user.email", "stackmap@example.invalid"], + ); + std::fs::write(directory.path().join("file.txt"), "base\n").unwrap(); + git(directory.path(), &["add", "file.txt"]); + git(directory.path(), &["commit", "-m", "base"]); + let main_oid = git(directory.path(), &["rev-parse", "HEAD"]); + git(directory.path(), &["checkout", "-b", "a"]); + std::fs::write(directory.path().join("file.txt"), "base\nadded\n").unwrap(); + git(directory.path(), &["commit", "-am", "add"]); + let a_oid = git(directory.path(), &["rev-parse", "HEAD"]); + git(directory.path(), &["checkout", "-b", "b"]); + std::fs::write(directory.path().join("file.txt"), "base\n").unwrap(); + git(directory.path(), &["commit", "-am", "revert"]); + let b_oid = git(directory.path(), &["rev-parse", "HEAD"]); + + let branches = vec![ + test_branch("main", &main_oid, None, None, "main", true), + test_branch("a", &a_oid, None, Some("main"), "a", false), + test_branch("b", &b_oid, Some("a"), Some("a"), "a", false), + ]; + let adapter = GitAdapter::discover(directory.path()).unwrap(); + let snapshot = Arc::new(RepositorySnapshot { + generation: 1, + root: directory.path().to_path_buf(), + git_dir: directory.path().join(".git"), + common_dir: directory.path().join(".git"), + repository_id: Arc::from("diff-test"), + default_trunk: Some(BranchId::new("main")), + configured_trunks: Arc::from([BranchId::new("main")]), + trunks: Arc::from([BranchId::new("main")]), + graphite_children: Arc::from([ + (BranchId::new("main"), Arc::from([BranchId::new("a")])), + (BranchId::new("a"), Arc::from([BranchId::new("b")])), + ]), + branch_index: RepositorySnapshot::index_branches(&branches), + branches: branches.into(), + stack_diffs: Arc::new(HashMap::from([(BranchId::new("a"), DiffState::Loading)])), + state: RepositoryState::Ready, + graphite_status: Arc::from("fixture"), + stale_error: None, + }); + let latest = Arc::new(AtomicU64::new(1)); + let partials = std::sync::Mutex::new(Vec::new()); + let enriched = enrich( + snapshot, + &adapter, + &mut DiffCache::new(8), + 4, + &latest, + &|partial| partials.lock().unwrap().push(partial), + ) + .unwrap(); + + let partials = partials.into_inner().unwrap(); + assert_eq!(partials.len(), 1); + assert!(matches!( + partials[0].branch(&BranchId::new("a")).unwrap().diff, + DiffState::Ready(_) + )); + assert_eq!( + partials[0].stack_diffs[&BranchId::new("a")], + DiffState::Loading + ); + + assert_eq!( + enriched.stack_diffs[&BranchId::new("a")], + DiffState::Ready(DiffStat::default()) + ); + assert_eq!( + enriched.branch(&BranchId::new("a")).unwrap().diff, + DiffState::Ready(DiffStat { + insertions: 1, + deletions: 0, + files: 1, + binary_files: 0, + }) + ); + assert_eq!( + enriched.branch(&BranchId::new("b")).unwrap().diff, + DiffState::Ready(DiffStat { + insertions: 0, + deletions: 1, + files: 1, + binary_files: 0, + }) + ); + } + + fn git(cwd: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + } + + fn test_branch( + name: &str, + oid: &str, + parent: Option<&str>, + diff_parent: Option<&str>, + root: &str, + current: bool, + ) -> Branch { + Branch { + id: BranchId::new(name), + oid: Arc::from(oid), + parent: parent.map(BranchId::new), + diff_parent: diff_parent.map(BranchId::new), + stack_root: BranchId::new(root), + trunk: Some(BranchId::new("main")), + graphite: GraphiteProvenance::Tracked, + committed_at: 1, + current, + dirty: false, + worktree: None, + configured_upstream: ConfiguredUpstream::None, + remote_ref: RemoteRefEvidence::NotRequested, + diff: DiffState::Loading, + pr: None, + } + } } diff --git a/src/refresh/mod.rs b/src/refresh/mod.rs index 10901a6..fddbc61 100644 --- a/src/refresh/mod.rs +++ b/src/refresh/mod.rs @@ -182,8 +182,17 @@ impl RefreshHandle { }; snapshot }; - if let Some(enriched) = - diffstats::enrich(snapshot, &adapter, &mut cache, 4, &latest_generation) + let publish_partial = |partial| { + push_event(&events, RefreshEvent::Enriched(partial)); + }; + if let Some(enriched) = diffstats::enrich( + snapshot, + &adapter, + &mut cache, + 4, + &latest_generation, + &publish_partial, + ) { push_event(&events, RefreshEvent::Enriched(enriched)); } diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 2ed7917..ea3c7b8 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -27,6 +27,7 @@ pub struct RenderGeometry { pub time: Option, pub diff: ColumnRange, pub worktree: ColumnRange, + pub remote: Option, pub pr: Option, } @@ -35,12 +36,24 @@ impl RenderGeometry { let width = width as usize; let wide_worktree = mode != WidthMode::Narrow; let show_time = width >= 56; + let show_remote = width >= 72; let show_pr = width >= 72; let time_width = usize::from(show_time) * 6; - let diff_width = 9; + let time_diff_gap = usize::from(show_time); + let diff_width = 10; + let diff_worktree_gap = 1; let worktree_width = if wide_worktree { 18 } else { 2 }; + let remote_width = usize::from(show_remote) * 11; let pr_width = usize::from(show_pr) * 8; - let metadata_width = time_width + diff_width + worktree_width + pr_width; + let pr_edge_gap = usize::from(show_pr); + let metadata_width = time_width + + time_diff_gap + + diff_width + + diff_worktree_gap + + worktree_width + + remote_width + + pr_width + + pr_edge_gap; let metadata_start = width.saturating_sub(metadata_width); let mut cursor = metadata_start; let time = show_time.then(|| { @@ -48,19 +61,27 @@ impl RenderGeometry { x: cursor, width: time_width, }; - cursor += time_width; + cursor += time_width + time_diff_gap; range }); let diff = ColumnRange { x: cursor, width: diff_width, }; - cursor += diff_width; + cursor += diff_width + diff_worktree_gap; let worktree = ColumnRange { x: cursor, width: worktree_width, }; cursor += worktree_width; + let remote = show_remote.then(|| { + let range = ColumnRange { + x: cursor, + width: remote_width, + }; + cursor += remote_width; + range + }); let pr = show_pr.then_some(ColumnRange { x: cursor, width: pr_width, @@ -99,6 +120,7 @@ impl RenderGeometry { time, diff, worktree, + remote, pr, } } @@ -149,7 +171,7 @@ pub fn width_mode(width: u16) -> WidthMode { } } -pub fn areas(area: Rect) -> Areas { +pub fn areas(area: Rect, show_detail: bool) -> Areas { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -159,7 +181,7 @@ pub fn areas(area: Rect) -> Areas { ]) .split(area); let mode = width_mode(area.width); - let (body, detail) = if mode == WidthMode::Wide { + let (body, detail) = if mode == WidthMode::Wide && show_detail { let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b748309..882b3d1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,7 +10,7 @@ use ratatui::Frame; use crate::app::{App, MutationState, Overlay}; pub fn render(frame: &mut Frame<'_>, app: &mut App, now: SystemTime) { - let areas = layout::areas(frame.area()); + let areas = layout::areas(frame.area(), app.detail_sidebar); panels::header(frame, areas.header, app); app.set_viewport_height(areas.body.height as usize); tree::render_with_mode(frame, areas.body, app, now, areas.mode); @@ -18,6 +18,10 @@ pub fn render(frame: &mut Frame<'_>, app: &mut App, now: SystemTime) { panels::detail(frame, detail, app); } panels::footer(frame, areas.footer, app); + if matches!(app.mutation, MutationState::ConfirmingCheckout(_)) { + panels::checkout_confirmation(frame, app); + return; + } if matches!(app.mutation, MutationState::ConfirmingDeletion(_)) { panels::deletion_confirmation(frame, app); return; diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 67319dd..bc02e15 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -46,9 +46,10 @@ pub fn header(frame: &mut Frame<'_>, area: Rect, app: &App) { pub fn footer(frame: &mut Frame<'_>, area: Rect, app: &App) { let position = app - .selected - .as_ref() - .and_then(|selected| app.projection.branch_to_selectable.get(selected).copied()) + .projection + .navigation_visual_rows + .iter() + .position(|row| Some(*row) == app.selected_visual_row_for_ui()) .map(|index| index + 1) .unwrap_or(0); let progress = app.mutation_progress(); @@ -58,11 +59,8 @@ pub fn footer(frame: &mut Frame<'_>, area: Rect, app: &App) { || app.refresh_error.is_some(); let mut state = if app.overlay == Overlay::Search { format!(" /{}", app.filter) - } else if let Overlay::StackNameEditor(editor) = &app.overlay { - format!( - " NAME {}: {} Enter save · empty clears · Esc cancel", - editor.target, editor.draft - ) + } else if let Overlay::StackNameEditor(_) = &app.overlay { + " NAME Enter save · empty clears · Esc cancel".to_owned() } else if let Overlay::ArchiveRange(range) = &app.overlay { format!( " RANGE {} {} branches {} → {} ↑↓ resize Enter {} Esc cancel", @@ -115,7 +113,7 @@ pub fn footer(frame: &mut Frame<'_>, area: Rect, app: &App) { } else { format!( " {position}/{} {order} {} {pitch} a View {archive_target} x {archive_action} v range X delete ↑↓ {stack_navigation} ? help", - app.projection.selectable.len(), + app.projection.navigation.len(), app.scope_label(), ) } @@ -186,7 +184,7 @@ pub fn help(frame: &mut Frame<'_>, app: &App) { GitHubState::Unavailable(error) => format!("unavailable: {error}"), }; let text = format!( - "Markers: › selected ○ branch ● current ◉ trunk\n ■ range * dirty ⎇ worktree\n\n↑/↓ or j/k previous/next branch\nShift/Cmd+↑/↓ J/K adjacent stack head, otherwise ±10 rows\nAlt+↑/↓ g/G top/bottom branch of current section\nt / T Recent/Graphite toggle / order picker\n+ / - / 0 adjust / reset lane pitch\nh focus selected stack; repeat exits\nH focus trunk or all Untrunked; repeat exits\ns toggle stack spacing\na toggle Active / Archive view\nv + arrows preview contiguous archive/restore range\n/ filter branch names\nEnter protected git switch\nc / C cycle color / color picker\nn name / clear selected stack\nx archive / restore selected local branch\nX guarded delete exact local branch\nr full reconciliation\no / y open / copy PR URL\nEsc close message/help\nq or Ctrl-C quit\n\nLowercase x/v change local config only; uppercase X can delete one exact local ref after confirmation. No remote changes or fetch.\nArchive view shows dim, nonselectable ancestry for stack context.\nFocused sections pin their trunk/bottom row.\nArchive evidence uses local remote-tracking refs only; no fetch.\nColors: stack identity; yellow PR; green/red diff\nActive: {} / {} / {}\n\nGraphite: {graphite}\nGitHub: {github}", + "Markers: › selected ○ branch ● current ◉ trunk\n ■ range * dirty ⎇ worktree\nRemote: ✓ pushed ↑ ahead ↓ behind ↕ diverged\n × gone ○ no remote ? unknown\n\n↑/↓ or j/k previous/next branch\nShift/Cmd+↑/↓ J/K adjacent stack head, otherwise ±10 rows\nAlt+↑/↓ g/G top/bottom branch of current section\nt / T Recent/Graphite toggle / order picker\n+ / - / 0 adjust / reset lane pitch\nh focus selected stack; repeat exits\nH focus trunk or all Untrunked; repeat exits\ns toggle stack spacing\na toggle Active / Archive view\nv + arrows preview contiguous archive/restore range\n/ filter branch names\nEnter ×2 arm / confirm protected git switch\nc / C cycle color / color picker\nn name / clear selected stack\nx archive / restore selected local branch\nX guarded delete exact local branch\nr full reconciliation\no / y open / copy PR URL\nEsc close message/help\nq or Ctrl-C quit\n\nLowercase x/v change local config only; uppercase X can delete one exact local ref after confirmation. No remote changes or fetch.\nArchive view shows dim, nonselectable ancestry for stack context.\nFocused sections pin their trunk/bottom row.\nRemote evidence uses local remote-tracking refs only; no fetch.\nColors: stack identity; yellow PR; green/red diff\nActive: {} / {} / {}\n\nGraphite: {graphite}\nGitHub: {github}", match app.order_mode { OrderMode::Recent => "recent order", OrderMode::Alphabetical => "alphabetical order", @@ -200,6 +198,10 @@ pub fn help(frame: &mut Frame<'_>, app: &App) { "separators off" } ); + let text = text.replace( + "Enter ×2 arm / confirm protected git switch\nc / C cycle color / color picker\nn name / clear selected stack", + "Enter ×2 / Enter git switch / edit selected label\nc / C contextual color / color picker\nd toggle wide detail sidebar\ni toggle visual section boundary\nn create missing stack/section label", + ); frame.render_widget( Paragraph::new(text) .wrap(Wrap { trim: false }) @@ -270,6 +272,26 @@ pub fn color_picker(frame: &mut Frame<'_>, app: &App) { ); } +pub fn checkout_confirmation(frame: &mut Frame<'_>, app: &App) { + let MutationState::ConfirmingCheckout(target) = &app.mutation else { + return; + }; + let area = centered(frame.area(), 64, 9); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(format!( + "Switch to this branch?\n\n{target}\n\nEnter switch · Esc cancel" + )) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .title(" Confirm switch ") + .borders(Borders::ALL), + ), + area, + ); +} + pub fn deletion_confirmation(frame: &mut Frame<'_>, app: &App) { let MutationState::ConfirmingDeletion(confirmation) = &app.mutation else { return; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 15268ee..049c708 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -5,6 +5,32 @@ use ratatui::style::Color; use crate::config::Config; use crate::model::BranchId; +#[cfg(test)] +thread_local! { + static TEST_NO_COLOR: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn no_color_requested() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return true; + } + #[cfg(test)] + if TEST_NO_COLOR.with(std::cell::Cell::get) { + return true; + } + false +} + +#[cfg(test)] +pub(crate) fn with_no_color(operation: impl FnOnce() -> T) -> T { + TEST_NO_COLOR.with(|flag| { + let previous = flag.replace(true); + let result = operation(); + flag.set(previous); + result + }) +} + #[cfg(test)] pub const TRUNK_COLOR_HEX: &str = "#e0af68"; const TRUNK_RGB: (u8, u8, u8) = (224, 175, 104); @@ -21,7 +47,7 @@ const PALETTE: &[(u8, u8, u8)] = &[ ]; pub fn stack_color(repository_id: &str, root: &BranchId, config: &Config) -> Color { - if std::env::var_os("NO_COLOR").is_some() { + if no_color_requested() { return Color::Reset; } if let Some(value) = config.color(root).and_then(parse_hex) @@ -37,7 +63,7 @@ pub fn stack_color(repository_id: &str, root: &BranchId, config: &Config) -> Col } pub fn trunk_color() -> Color { - if std::env::var_os("NO_COLOR").is_some() { + if no_color_requested() { Color::Reset } else { Color::Rgb(TRUNK_RGB.0, TRUNK_RGB.1, TRUNK_RGB.2) @@ -48,8 +74,15 @@ pub fn selected_background() -> Color { Color::Rgb(52, 68, 92) } -pub fn current_background() -> Color { - Color::Rgb(31, 38, 52) +pub fn current_background(accent: Color) -> Color { + match accent { + Color::Rgb(red, green, blue) => Color::Rgb( + (red as f32 * 0.4).round() as u8, + (green as f32 * 0.4).round() as u8, + (blue as f32 * 0.4).round() as u8, + ), + _ => Color::Rgb(31, 38, 52), + } } fn parse_hex(value: &str) -> Option { @@ -59,3 +92,11 @@ fn parse_hex(value: &str) -> Option { u8::from_str_radix(value.get(5..7)?, 16).ok()?, )) } + +pub fn visual_section_color(value: &str) -> Color { + if no_color_requested() { + Color::Reset + } else { + parse_hex(value).unwrap_or(Color::Reset) + } +} diff --git a/src/ui/tree.rs b/src/ui/tree.rs index 28dc8bf..5c129cf 100644 --- a/src/ui/tree.rs +++ b/src/ui/tree.rs @@ -6,14 +6,17 @@ use ratatui::widgets::Paragraph; use std::path::Path; use std::time::SystemTime; -use crate::app::App; +use crate::app::{App, ConfigTarget, Overlay}; use crate::model::topology::{ ArchiveMode, ConnectorRow, DividerRow, Emphasis, ProjectedRow, ProjectionEntry, StackLabelRow, + VisualSectionDividerRow, VisualSectionLabelRow, }; use crate::model::{Branch, BranchId, ConfiguredUpstream, DiffState, RemoteRefEvidence}; use super::layout::{ColumnRange, RenderGeometry, WidthMode}; -use super::theme::{current_background, selected_background, stack_color, trunk_color}; +use super::theme::{ + current_background, selected_background, stack_color, trunk_color, visual_section_color, +}; mod connectors; mod details; @@ -108,6 +111,12 @@ pub fn render_with_mode( ProjectionEntry::StackLabel(label) => { stack_label_line(app, visual_row, label, geometry) } + ProjectionEntry::VisualSectionLabel(label) => { + visual_section_label_line(app, visual_row, label, geometry) + } + ProjectionEntry::VisualSectionDivider(divider) => { + visual_section_divider_line(app, visual_row, divider, geometry) + } ProjectionEntry::Divider(divider) => divider_line(app, visual_row, divider, geometry), ProjectionEntry::Branch(row) => { let Some(branch) = snapshot.branch(&row.branch) else { @@ -118,9 +127,15 @@ pub fn render_with_mode( }; lines.push(line); } + let rendered_height = lines.len() as u16; + let content_y = if sticky.is_some() && focused_bounds.is_some() { + area.y + scroll_height.saturating_sub(rendered_height) + } else { + area.y + }; frame.render_widget( Paragraph::new(lines), - Rect::new(area.x, area.y, area.width, scroll_height), + Rect::new(area.x, content_y, area.width, rendered_height), ); if let Some(sticky_row) = sticky @@ -153,6 +168,105 @@ pub fn render_with_mode( } } +fn visual_section_label_line( + app: &App, + visual_row: usize, + label: &VisualSectionLabelRow, + geometry: RenderGeometry, +) -> Line<'static> { + let mut cells = blank_cells(geometry.width); + let (bits, styles) = rail_bits(app, visual_row, geometry); + paint_bits(&mut cells, &bits, &styles, geometry.metadata_start); + let desired_x = geometry + .name_x(label.lane) + .saturating_add(label.manual_depth.saturating_mul(2)); + let x = desired_x.min(geometry.metadata_start.saturating_sub(2)); + let text = if x < desired_x { + format!("{} {}", label.manual_depth, label.text) + } else { + label.text.to_string() + }; + let width = geometry.metadata_start.saturating_sub(x + 1); + let cursor = match &app.overlay { + Overlay::StackNameEditor(editor) + if editor.target == ConfigTarget::VisualSection(label.anchor.clone()) => + { + Some(editor.cursor) + } + _ => None, + }; + let text = inline_editor_text(&text, width, cursor); + put_text( + &mut cells, + x, + width, + &text, + emphasized( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + label.emphasis, + ), + ); + let selected = app.selected_label.as_ref() + == Some(&crate::app::ConfigTarget::VisualSection( + label.anchor.clone(), + )); + if selected { + set_symbol( + &mut cells, + 1, + "›", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + for cell in &mut cells { + cell.style = cell.style.fg(Color::White); + } + } + let selected_background = selected.then(|| { + let color = visual_section_color(&label.color); + if color == Color::Reset { + selected_background() + } else { + color + } + }); + cells_to_line(cells, selected_background) +} + +fn visual_section_divider_line( + app: &App, + visual_row: usize, + divider: &VisualSectionDividerRow, + geometry: RenderGeometry, +) -> Line<'static> { + let mut cells = blank_cells(geometry.width); + let (bits, styles) = rail_bits(app, visual_row, geometry); + paint_bits(&mut cells, &bits, &styles, geometry.metadata_start); + let desired_x = geometry + .name_x(divider.lane) + .saturating_add(divider.manual_depth.saturating_mul(2)); + let x = desired_x.min(geometry.metadata_start.saturating_sub(2)); + let divider_text = if x < desired_x { + format!("{}─", divider.manual_depth) + } else { + "────────".to_owned() + }; + put_text( + &mut cells, + x, + geometry.metadata_start.saturating_sub(x + 1), + ÷r_text, + emphasized( + Style::default().fg(visual_section_color(÷r.color)), + divider.emphasis, + ), + ); + cells_to_line(cells, None) +} + fn section_line(title: &str, geometry: RenderGeometry) -> Line<'static> { let mut cells = blank_cells(geometry.width); put_text( @@ -179,11 +293,34 @@ fn stack_label_line( if geometry.lane_overflows(label.lane) { paint_overflow_cue(&mut cells, geometry); } + let width = geometry.name_width(label.lane); + let cursor = match &app.overlay { + Overlay::StackNameEditor(editor) + if editor.target == ConfigTarget::Stack(label.stack_id.clone()) => + { + Some(editor.cursor) + } + _ => None, + }; + let count = format!( + " · {} {}", + label.branch_count, + if label.branch_count == 1 { + "branch" + } else { + "branches" + } + ); + let name_width = width.saturating_sub(count.chars().count()); + let text = format!( + "{}{count}", + inline_editor_text(&label.text, name_width, cursor) + ); put_text( &mut cells, geometry.name_x(label.lane), - geometry.name_width(label.lane), - &label.text, + width, + &text, emphasized( Style::default() .fg(Color::White) @@ -191,7 +328,41 @@ fn stack_label_line( label.emphasis, ), ); - cells_to_line(cells, None) + if let Some(diff) = app + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.stack_diffs.get(&label.stack_id)) + { + paint_diff(&mut cells, geometry.diff, diff, label.emphasis); + } + let selected = app.selected_label.as_ref() + == Some(&crate::app::ConfigTarget::Stack(label.stack_id.clone())); + if selected { + set_symbol( + &mut cells, + 1, + "›", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + for cell in &mut cells { + cell.style = cell.style.fg(Color::White); + } + } + let selected_background = selected.then(|| { + let color = app + .snapshot + .as_ref() + .map(|snapshot| stack_color(&snapshot.repository_id, &label.stack_id, &app.config)) + .unwrap_or(Color::Reset); + if color == Color::Reset { + selected_background() + } else { + color + } + }); + cells_to_line(cells, selected_background) } fn divider_line( @@ -249,7 +420,7 @@ fn branch_line( now: SystemTime, geometry: RenderGeometry, ) -> Line<'static> { - let selected = app.selected.as_ref() == Some(&branch.id); + let selected = app.selected_label.is_none() && app.selected.as_ref() == Some(&branch.id); let background = if selected { let accent = if row.is_trunk { trunk_color() @@ -267,7 +438,17 @@ fn branch_line( accent }) } else if branch.current { - Some(current_background()) + let accent = if row.is_trunk { + trunk_color() + } else { + let repository_id = app + .snapshot + .as_ref() + .map(|snapshot| snapshot.repository_id.as_ref()) + .unwrap_or_default(); + stack_color(repository_id, &row.stack_id, &app.config) + }; + Some(current_background(accent)) } else { None }; @@ -320,15 +501,34 @@ fn branch_line( ); } - let name_x = geometry.name_x(row.lane); - let name_width = geometry.name_width(row.lane); + let desired_name_x = geometry + .name_x(row.lane) + .saturating_add(row.manual_depth.saturating_mul(2)); + let name_x = desired_name_x.min(geometry.metadata_start.saturating_sub(2)); + let name_width = geometry + .metadata_start + .saturating_sub(name_x.saturating_add(1)); let dirty = if branch.dirty { "*" } else { "" }; + let branch_name = if name_x < desired_name_x { + format!("{} {}{dirty}", row.manual_depth, branch.id) + } else { + format!("{}{dirty}", branch.id) + }; put_text( &mut cells, name_x, name_width, - &format!("{}{dirty}", branch.id), - identity, + &branch_name, + if selected { + identity + } else if let Some(color) = &row.visual_color { + emphasized( + Style::default().fg(visual_section_color(color)), + row.emphasis, + ) + } else { + identity + }, ); let metadata_emphasis = row.emphasis; @@ -350,18 +550,31 @@ fn branch_line( branch.worktree.as_deref(), metadata_emphasis, ); + if let Some(range) = geometry.remote { + paint_remote_status(&mut cells, branch, range, metadata_emphasis); + } if let Some(range) = geometry.pr && let Some(pr) = &branch.pr { put_right( &mut cells, range, - &format!("#{}", pr.number), + match pr.status { + crate::model::PullRequestStatus::Open => format!("#{}", pr.number), + status => status.label().to_owned(), + } + .as_str(), emphasized(Style::default().fg(Color::Yellow), metadata_emphasis), ); } } + if selected { + for cell in &mut cells { + cell.style = cell.style.fg(Color::Black); + } + } + cells_to_line(cells, background) } @@ -376,7 +589,7 @@ fn paint_archive_evidence( width: geometry.width.saturating_sub(geometry.metadata_start), }; let compact = range.width < 20; - let mut badges = Vec::with_capacity(3); + let mut badges = Vec::with_capacity(2); if let Some(path) = branch.worktree.as_deref() { badges.push(if compact { "⎇".to_owned() @@ -389,18 +602,9 @@ fn paint_archive_evidence( format!("⎇ {}", truncate(basename, 10)) }); } - if let Some(upstream) = upstream_badge(&branch.configured_upstream, compact) { - badges.push(upstream); - } - badges.push(remote_badge(&branch.remote_ref, compact)); + let (remote, color) = remote_status(branch, compact); + badges.push(remote); let label = truncate(&badges.join(" "), range.width); - let color = match branch.remote_ref { - RemoteRefEvidence::Contained { .. } => Color::Green, - RemoteRefEvidence::LocalOnly { .. } => Color::Yellow, - RemoteRefEvidence::Checking - | RemoteRefEvidence::NotRequested - | RemoteRefEvidence::Unavailable { .. } => Color::DarkGray, - }; put_text( cells, range.x, @@ -410,86 +614,108 @@ fn paint_archive_evidence( ); } -fn upstream_badge(upstream: &ConfiguredUpstream, compact: bool) -> Option { - Some(match upstream { - ConfiguredUpstream::None => return None, +fn paint_remote_status( + cells: &mut [RenderCell], + branch: &Branch, + range: ColumnRange, + emphasis: Emphasis, +) { + let (label, color) = remote_status(branch, range.width < 10); + put_right( + cells, + range, + &truncate(&label, range.width), + emphasized(Style::default().fg(color), emphasis), + ); +} + +fn remote_status(branch: &Branch, compact: bool) -> (String, Color) { + let label = match &branch.configured_upstream { + ConfiguredUpstream::None => match &branch.remote_ref { + RemoteRefEvidence::Contained { .. } => { + if compact { + "✓".into() + } else { + "✓ pushed".into() + } + } + RemoteRefEvidence::Checking => { + if compact { + "…".into() + } else { + "… checking".into() + } + } + RemoteRefEvidence::LocalOnly { .. } | RemoteRefEvidence::NotRequested => { + if compact { + "○".into() + } else { + "○ no remote".into() + } + } + RemoteRefEvidence::Unavailable { .. } => { + if compact { + "?".into() + } else { + "? remote".into() + } + } + }, ConfiguredUpstream::Equal { .. } => { if compact { - "=".into() + "✓".into() } else { - "up =".into() + "✓ pushed".into() } } ConfiguredUpstream::Ahead { ahead, .. } => { if compact { format!("↑{ahead}") } else { - format!("up ↑{ahead}") + format!("↑{ahead} ahead") } } ConfiguredUpstream::Behind { behind, .. } => { if compact { format!("↓{behind}") } else { - format!("up ↓{behind}") + format!("↓{behind} behind") } } ConfiguredUpstream::Diverged { ahead, behind, .. } => { if compact { - format!("↑{ahead}↓{behind}") + format!("↕{ahead}/{behind}") } else { - format!("up ↑{ahead}↓{behind}") + format!("↕{ahead}/{behind} div") } } ConfiguredUpstream::Gone { .. } => { if compact { - "gone".into() + "×".into() } else { - "up gone".into() + "× gone".into() } } ConfiguredUpstream::Unavailable { .. } => { if compact { - "up?".into() - } else { - "upstream ?".into() - } - } - }) -} - -fn remote_badge(evidence: &RemoteRefEvidence, compact: bool) -> String { - match evidence { - RemoteRefEvidence::Contained { .. } => { - if compact { - "r✓" - } else { - "remote-ref ✓" - } - } - RemoteRefEvidence::LocalOnly { .. } => { - if compact { - "local" - } else { - "local only" - } - } - RemoteRefEvidence::Checking => { - if compact { - "…" + "?".into() } else { - "checking…" + "? remote".into() } } - RemoteRefEvidence::NotRequested | RemoteRefEvidence::Unavailable { .. } => { - if compact { - "r?" - } else { - "remote ?" - } - } - } - .into() + }; + let color = match &branch.configured_upstream { + ConfiguredUpstream::Equal { .. } => Color::Green, + ConfiguredUpstream::Ahead { .. } | ConfiguredUpstream::None => match &branch.remote_ref { + RemoteRefEvidence::Contained { .. } => Color::Green, + RemoteRefEvidence::Checking | RemoteRefEvidence::Unavailable { .. } => Color::DarkGray, + _ => Color::Yellow, + }, + ConfiguredUpstream::Behind { .. } => Color::Cyan, + ConfiguredUpstream::Diverged { .. } | ConfiguredUpstream::Gone { .. } => Color::Red, + ConfiguredUpstream::Unavailable { .. } => Color::DarkGray, + }; + (label, color) } fn rail_bits(app: &App, visual_row: usize, geometry: RenderGeometry) -> (Vec, Vec