Skip to content

Mac app: keyboard-shortcut parity with the TUI - #946

Merged
anutron merged 27 commits into
masterfrom
argus/mac-keybinding-parity
Aug 21, 2026
Merged

Mac app: keyboard-shortcut parity with the TUI#946
anutron merged 27 commits into
masterfrom
argus/mac-keybinding-parity

Conversation

@anutron

@anutron anutron commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports the TUI's useful keyboard actions into the macOS companion app so a user working entirely from the mac app never has to fall back to opening the TUI:

  • Global chrome shortcuts: tab switch (⌘1-4), shortcuts-help sheet (⇧⌘/), destroy (⌘⌫), fork (⇧⌘B), open-repo (⇧⌘E), open-PR (⇧⌘U, works from both global scope and while the terminal is focused), jump-to-next-needs-input (⇧⌘J), and a "Prune Stale Worktrees" item in a new app-chrome-level toolbar overflow menu.
  • Task rail: right-click context menu for status advance/revert, ⇧⌘A archive, and a new ⇧⌘P pin action (pin didn't exist in the mac app before this change).
  • Task rail filter: ⌘F focuses a new sidebar filter field; a persistent, always-visible toggle shows/hides hera-managed tasks.
  • Terminal tab: ⌘↑/↓ (task switch), ⌘←/→ (detail-tab cycling — the mac app has no split-pane view, so this is the resolved analog to the TUI's pane-focus chord), ⇧↑/↓/PageUp/PageDown/End (scrollback), and ⇧⌘C (copy visible output) — all intercepted by a local NSEvent monitor before they reach SwiftTerm, so they never leak into the agent's input stream. Every other keystroke, including all Ctrl chords, is forwarded unchanged (non-regression, verified both by a chord-collision test and a code-level trace of the monitor's two-path structure).
  • A "Switch Claude session" picker sheet, reachable via a new toolbar button in the Terminal tab.

Scope correction found during implementation: the Claude-session-switcher piece needed two new minimal daemon REST endpoints (GET/POST /api/tasks/{id}/claude-session(s)) — internal/claudesession had no HTTP route, only in-process TUI access. This was escalated to and approved by the coordinator; see design.md D3 in the archived change for the full rationale. The session-switch endpoint reuses the existing Runner.KickRerender primitive rather than a hand-rolled stop+start, avoiding a session-map race.

Explicitly deferred (named follow-ups, not silently dropped): a global command palette, a global task/role switcher beyond direct sidebar selection, restore-rail, copy branch/path, manual refresh, Files-tab per-row actions, all Hera-rail mutation keys, and — newly — the web SPA's own use of the two new session-switcher endpoints (Frontend Parity note, matching the existing "hera mutations are TUI-only" precedent).

Process

Built via /execute-plan against openspec/changes/add-mac-keybinding-parity (proposal/design/delta specs pre-approved): 7 stages, most run in parallel worktrees, each through a two-stage review (spec compliance, then code quality) before merging back. Every review-found issue was fixed and re-verified, not waved through — including an independently-traced concurrency claim in the daemon-side session switch, and a SwiftTerm API risk (pageUp()/pageDown() silently forward bytes to the PTY in alt-screen mode) caught by reading the vendored source rather than trusting the convenience API. Change archived in this same PR per this repo's CLAUDE.md.

Test plan

  • make pre-pr — build/vet/fmt-check/lint-pr clean; vuln's failures are pre-existing stdlib-only CVEs (CI-advisory, continue-on-error); test-cover-gate passes at 88.7% filtered coverage once the sandbox's own ARGUS_* env vars are excluded from the internal/agent subprocess (documented pre-existing hera-worker-sandbox artifact, confirmed unrelated to this change)
  • make mac-build && make mac-test — 244/244 tests, 19 suites, clean
  • openspec validate --all --strict — 60/60 specs pass
  • Manual smoke test in a real running app (no XCUITest harness exists in this repo; every SwiftUI/AppKit-only code path was verified via build success + careful code-level review instead, documented per-stage)

🤖 Generated with Claude Code

anutron and others added 27 commits August 21, 2026 11:52
Converged the TUI keymap into a per-key mac-app resolution
(shortcuts, context menus, or deferred) via user brainstorm.
…ng-parity)

TDD red phase for the five ArgusKit pure-logic seams later stages implement:
TaskStatus.advanced()/reverted(), NeedsInputNavigation.next(), TerminalChords'
intercepted-chord allowlist, ArgusClient.setPinned (raw JSON round trip), and
ArgusClient.pruneCompleted. None of the referenced APIs exist yet, so
make mac-build/mac-test fail to compile by design, pinning the exact
signatures Stages 2-6 implement against.

Also extends MockURLProtocol with a request log so a test can assert
GET-then-PUT ordering for the pin round trip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…n endpoints

Implementation surfaced a real gap in design.md's "no daemon/REST API
changes" claim: internal/claudesession has no HTTP route, so the mac app
(a REST-only client) can't reach it for the Claude session switcher (D3).
Escalated to and approved by the hera coordinator. Adds a minimal
GET/POST /api/tasks/{id}/claude-session(s) delta under specs/rest-api/,
corrects design.md/proposal.md's Impact sections, and names the web SPA's
own use of these endpoints as an explicit follow-up per this repo's
Frontend Parity rule.
Task rail filter access, per openspec/changes/add-mac-keybinding-parity:
- Sidebar.swift gains a filter bar with a case-insensitive substring
  filter over task name, and a persistent visible toggle for hera-managed
  task visibility (default on, matching current behavior).
- Cmd+F focuses the filter field via a hidden button + @focusstate, the
  same "shortcut on the performing button" pattern as the existing
  Cmd+N/Cmd+R toolbar shortcuts.
- AppState gains heraManagedTaskIDs, rebuilt from GET /api/hera (there is
  no such field on the ArgusKit.Task wire shape) alongside every task
  snapshot, mirroring how HeraTab already cross-references role bindings
  by task id.

Documents both the hera-managed-derivation and the Cmd+F/SwiftTerm-focus
open question in gotchas/macos-app.md.

No new mac-build/mac-test failures: the full-package build is red on this
branch by Stage 1's own design (pending Stages 2/3/5's ArgusKit symbols);
verified the compile-error set is byte-identical before and after this
change, and the Argus target alone builds clean in isolation.
…eybinding-parity)

Implements the mac app's chrome-level shortcuts and menu items:

- NeedsInputNavigation.next(): pure cycling logic for jump-to-next-
  needs-input, making Stage 1's NeedsInputNavigationTests pass.
- ArgusClient.pruneCompleted() + PruneReport model: POSTs
  /api/maintenance/prune-completed, mirroring internal/apiclient's
  JSON shape; makes Stage 1's ClientRequestTests pruneCompleted case
  pass.
- AppState: openRepo(_:), openPR(for:), jumpToNextNeedsInput(),
  pruneCompleted() wrapper, isPresentingShortcutsHelp, and a new
  .pruneCompleted case on PendingConfirmation.
- TaskActionMenuItems: Open Repo / Open PR buttons, plus keyboard
  shortcuts on Fork/Delete/Open Repo/Open PR when showShortcuts is set
  (mirrors the existing Rename pattern; no centralized dispatch table
  per design.md D1).
- TaskDetailTabs: Cmd+1/2/3/4 tab-switch shortcuts.
- ContentView: Cmd+Shift+/ shortcuts-help sheet, Cmd+Shift+J jump-to-
  needs-input, and a new toolbar overflow ("…") menu with "Prune Stale
  Worktrees" (gated behind the existing confirmation-dialog mechanism).
- ShortcutsHelpSheet: new sheet listing the app's shortcuts (hardcoded
  static list per D1, including other stages' chords for a single
  future reference).

Chord table: Help ⇧⌘/, Destroy ⌘⌫, Fork ⇧⌘B, Open Repo ⇧⌘E, Open PR
⇧⌘U, Jump-to-needs-input ⇧⌘J — checked against the existing 4
shortcuts, this change's other fixed chords, and macOS HIG reserved
shortcuts (tasks.md 2.1).

Verified in isolation (Stage 3's setPinned/TaskStatus.advanced/
reverted and Stage 5's TerminalChords/KeyChord don't exist yet, so
the full make mac-build/mac-test can't go green until those stages
land): temporarily set aside TaskStatusTransitionTests.swift,
TerminalChordsTests.swift, and the setPinned cases in
ClientRequestTests.swift, confirmed clean build + all 152 remaining
tests pass (including every NeedsInputNavigation and pruneCompleted
case), then restored all three exactly via git checkout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…hortcuts

Stage 3 of add-mac-keybinding-parity:
- ArgusKit.TaskStatus.advanced()/reverted(), mirroring internal/model/status.go's
  Next()/Prev() clamp semantics; .other(_) is a no-op in both directions.
- ArgusClient.setPinned(id:pinned:), a GET/PUT round trip against
  /api/tasks/{id}/raw so fields the lossy Task model can't decode
  (session_id, result, ...) survive the write. Also mirrors the
  pinned/archived mutual-exclusivity invariant client-side, since
  handleUpdateTaskRaw's plain db.Update does not enforce it.
- TaskRow.swift: right-click context menu gains Advance/Revert Status,
  disabled at the ladder's clamp points.
- TaskActions.swift: Archive/Unarchive gets Cmd+Shift+A; a new Pin/Unpin
  action (net-new UI) gets Cmd+Shift+P.
- AppState: setStatus wrapper; pinnedTaskIDs client-side cache (lossy
  /api/tasks omits `pinned` entirely) backfilled on-demand via TaskRow/
  DetailView .task(id:) modifiers and kept current by optimistic updates
  plus archive/delete event reconciliation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Review finding: Sidebar's filteredFolders was a private View method reading
@State/@Environment directly, so the filter + hera-managed-toggle logic had
no unit test (tasks.md 1.3/4.3, design.md's Prove-It Pattern).

Move the actual predicate into a pure ArgusKit.TaskFiltering.filteredFolders
free function over plain data (folders, filterText, showHeraManaged,
heraManagedTaskIDs) -> [TaskFolder], mirroring Stage 1's other pure-logic
seams (NeedsInputNavigation, TaskStatus.advanced/reverted). Sidebar now just
calls it with its own state.

Adds TaskFilteringTests.swift covering: empty/whitespace filter text as a
no-op, a matching substring (case-insensitive), a non-matching substring
dropping the folder, the hera-managed toggle hiding/showing a managed task,
and the two filters combining (AND). Verified the exact same assertions
pass against the real ArgusKit source via a standalone smoke harness (the
in-repo ArgusKitTests executable still can't build — unrelated, pre-existing
Stage 1 stub symbols pending Stages 2/3/5).

make mac-build/mac-test: confirmed the compile-error set is still
byte-identical to the pre-fix baseline; no new failures introduced.
ArgusKit gains a ClaudeSession model (Codable, decoding mod_time into a
real Date rather than the SDK's usual raw RFC3339 String) and two
ArgusClient methods, claudeSessions(taskID:) and
switchClaudeSession(taskID:sessionID:), against the fixed daemon
contract from specs/rest-api/spec.md's "Claude session switcher" delta.
ArgusError gains isBadRequest to let callers distinguish a 400
(non-Claude-backed task) from other failures.

The Argus app wires a toolbar button into the Terminal tab (shown only
when the task's backend isn't a known non-Claude backend) that fetches
sessions and opens a new picker sheet, ClaudeSessionPickerSheet,
listing them newest-first with the current one checkmarked. Selecting
a session POSTs the switch; both "switched" and "unchanged" dismiss
the sheet, trusting the terminal tab's existing SSE reconnect (the
same exit{"rerendering":true} path an ordinary resize kick-restart
already uses) to pick up the new session's output. A 500 or other
failure surfaces via the existing ActionErrorBanner instead of
crashing or leaving a broken sheet open.

Documents three non-obvious gotchas: ISO8601DateFormatter can't be
cached in a static let under this package's strict-concurrency check,
modTime is the only ArgusKit timestamp decoded to a Date, and the
mac-side "is this task Claude-backed" check is an exclude-list mirroring
the daemon's own guard, not an include-list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Stage 3's review flagged that proposal.md/design.md/spec.md described
pin as "the existing... pin action" when it never existed in the mac
app before this change (confirmed: no pinned field on the lossy task
wire shape, no dedicated REST endpoint). Cosmetic but real spec drift.
…rror

Review flagged the highest-risk addition in Stage 3 (setPinned's
if pinned, case .bool(true)? = obj["archived"] flip) as untested: the
existing rawTaskFixture() never included an archived:true key, so the
flip branch never actually ran under test. Added an archived parameter
to the fixture and three new cases:
- pinning an already-archived task flips archived to false in the PUT
  body while every other key, including pinned itself, round-trips
- an already-false archived key is left alone (present, unchanged)
- an omitted archived key is never inserted

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Adds GET /api/tasks/{id}/claude-sessions and POST /api/tasks/{id}/claude-session,
the daemon-side REST mirror of the TUI's ctrl+r Claude session switcher
(internal/claudesession + app.go's openSessionPickerModal/switchSession), so
the REST-only mac app can reach the same data and mechanism. Implements D3 of
openspec/changes/add-mac-keybinding-parity/design.md.

The switch endpoint stops/restarts a live session via the existing
Runner.KickRerender primitive (never hand-rolled Stop+Start, which would race
the runner's own exit-cleanup goroutine) and bridges KickRerender's async gap
with a bounded poll on HasPendingRestart/Get so the response can carry the
new pid synchronously.
…ding-parity

# Conflicts:
#	macos/Sources/Argus/AppState.swift
#	macos/Sources/Argus/TaskActions.swift
…nding-parity

# Conflicts:
#	context/knowledge/gotchas/macos-app.md
#	context/knowledge/index.md
#	macos/Sources/Argus/AppState.swift
#	macos/Sources/Argus/ContentView.swift
Adds the Terminal tab's fixed 10-chord intercepted allowlist
(ArgusKit.TerminalChords/KeyChord) and wires it into a new local
NSEvent .keyDown monitor in FocusTakingTerminalView, alongside the
existing .leftMouseDown focus-reclaim monitor, so Cmd+Up/Down (task
switch), Cmd+Left/Right (detail-tab cycle), Shift+Up/Down/PageUp/
PageDown/End (scrollback), and Cmd+Shift+C (copy visible output)
never reach SwiftTerm's keyDown or POST /input.

- ArgusKit: KeyChord/TerminalChords (Stage 1's pinned allowlist),
  KeyChordDecoding (pure virtual-keycode table), TaskNavigation
  (clamped adjacent-task stepping), CyclicSelection (generic
  wrap-around stepping for detail-tab cycling) - all with tests.
- AppState: selectPreviousTask/selectNextTask, cycleDetailTab(forward:),
  task(withID:); terminalController(for:) wires the view's
  controller/appState back-references.
- TerminalController: scrollLineUp/Down, scrollPageUp/Down,
  scrollToBottom (SwiftTerm scrollUp/scrollDown/scroll(toPosition:),
  never pageUp()/pageDown() which can leak bytes to the PTY in
  alt-screen mode), copyVisibleOutput (Terminal.getLine + translateToString,
  not select-all+getSelection).
- A second, separate one-entry allowlist in the same monitor swallows
  Cmd+Shift+U as a terminal-focused fallback for the chrome-level
  Open PR shortcut, per Stage 2's review finding - kept structurally
  apart from TerminalChords.intercepted.
- ChromeShortcutCollisionTests pins disjointness against every real
  chrome-level .keyboardShortcut (tasks.md 5.4).
- Documents the mechanism in gotchas/macos-app.md.

make mac-build && make mac-test: 244 tests / 19 suites, all green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Stage 5's review flagged that specs/macos-app/spec.md, design.md, and
proposal.md still described this as "pane focus" (a TUI agent-view
concept that doesn't exist in the mac app, which has one terminal
surface per task, not a split-pane view). The implementation itself
was always correct (design.md's own D2 already required this exact
resolution be made during implementation); only the doc wording had
drifted. Non-blocking per the review, fixed before archive.
Documents the full new chord set in the macOS app's Reference section
(no such table existed before this change). Gotcha-doc coverage (7.1)
was already delivered by Stage 5; verified full mac-test green (7.3).
lint-pr flagged claudesessions.go's uint16(rows)/uint16(cols) as an
unchecked integer-overflow conversion. Added clampToUint16, mirroring
the existing pattern where /resize's rows/cols already arrive typed as
uint16 from the request body and never needed this — PTYSize() returns
plain ints here, so the conversion needs its own guard.
openspec archive add-mac-keybinding-parity: merges the macos-app and
rest-api deltas into their base specs and moves the change folder to
openspec/changes/archive/2026-08-21-add-mac-keybinding-parity/, per
this repo's CLAUDE.md (archive lands in the same PR, before merge).
Checked off 1.4/1.5/7.4 in the archived tasks.md — their work landed
in Stages 5/6 respectively, just wasn't retroactively marked done.
`openspec validate --all --strict`: 60/60 specs pass.
…g-parity

# Conflicts:
#	context/knowledge/index.md
@github-actions

Copy link
Copy Markdown

Merging this branch will decrease overall coverage

Impacted Packages Coverage Δ 🤖
github.com/drn/argus/internal/api 86.99% (-0.34%) 👎

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/drn/argus/internal/api/claudesessions.go 76.84% (+76.84%) 95 (+95) 73 (+73) 22 (+22) 🌟
github.com/drn/argus/internal/api/routes.go 97.87% (+0.03%) 141 (+2) 138 (+2) 3 👍
github.com/drn/argus/internal/api/server.go 86.11% (ø) 72 62 10

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/drn/argus/internal/api/claudesessions_test.go

@anutron
anutron merged commit 820ab59 into master Aug 21, 2026
1 check passed
anutron added a commit that referenced this pull request Aug 22, 2026
Resolves conflicts with the sibling add-mac-keybinding-parity change
(#946/#947), which merged first and, via shared branch ancestry from a
common batch-brainstorm starting point, both (a) genuinely overlapped
this branch in Sidebar.swift and (b) accidentally carried forward this
branch's already-archived openspec/changes/add-mac-hera-rail-toggle/
scaffolding as "new" from master's perspective (that squash-merge had
no shared file history with this branch's own archival commit, so git
resurrected the stale, all-unchecked pre-work copy instead of treating
it as already-deleted).

Conflict resolution:
- openspec/changes/add-mac-hera-rail-toggle/*: removed — resurrected
  stale/unarchived duplicate; this branch's completed, checked-off,
  base-spec-merged archive at
  openspec/changes/archive/2026-08-22-add-mac-hera-rail-toggle/ stands.
- macos/Sources/Argus/Sidebar.swift: real merge — kept this branch's
  sidebar-mode Picker (Tasks/Projects toggle) at the top level, nested
  master's filter bar + task-name filtering inside the `.tasks` case
  (filtering has no meaning in the Hera tree), adopted master's
  navigationTitle/onDeleteCommand placement at the outer VStack level
  since those apply regardless of mode.
- openspec/specs/macos-app/spec.md, context/knowledge/gotchas/macos-app.md,
  context/knowledge/index.md: purely additive on both sides (independent
  new requirements/gotcha entries) — concatenated, index.md bullet count
  corrected to reflect the merged total.
- openspec/specs/rest-api/spec.md: auto-merged cleanly, no manual changes.

Verified post-merge: go build ./... clean, make mac-build clean,
make mac-test 266/266 passing (161 from this branch + 105 from
add-mac-keybinding-parity), full `go test -race` suite green at 88.7%
coverage (floor 88%), openspec validate --all --strict 61/0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@anutron
anutron deleted the argus/mac-keybinding-parity branch August 23, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant