From 625e37cda6771eb0b510f62c435d3ccbeb36b838 Mon Sep 17 00:00:00 2001 From: davidsprague-udisc Date: Thu, 16 Apr 2026 18:11:02 -0400 Subject: [PATCH 1/2] feat: add skip-permissions option for claude sessions Adds an optional `--dangerously-skip-permissions` passthrough for Claude Code sessions launched by the dashboard. The option is opt-in per task: a new wizard step in the new-task flow and a resume-time prompt both use a multi-option Select (rather than a y/n Confirm) so a single keystroke can't bypass the security-sensitive choice. Also includes: - Stale-pointer fix on the resume prompt: the overlay now stores the task name instead of a *TaskView, and re-resolves at execute time. Re-checks HasSession before spawning so a session started while the prompt is open attaches instead of duplicating. - Shared helper for the resume overlay's form-completion handling so the two call sites can't drift. - Window-resize propagation for the resume overlay (mirrors the existing new-task overlay behavior). - Warp terminal: poll `frontmost of process "Warp"` instead of using blind `delay` calls so keystrokes can't leak to the wrong app under load; capture osascript output and translate error -1719 into a targeted Accessibility-permission message. - LaunchConfig: document that PlanMode and SkipPermissions are mutually exclusive and prefer PlanMode (the safer option) if both are set. Also lands the skip-permissions and warp-terminal design docs under docs/brainstorms and docs/plans. --- .../skip-permissions-requirements.md | 43 +++ .../warp-terminal-support-requirements.md | 39 +++ ...-16-001-feat-warp-terminal-support-plan.md | 110 +++++++ ...6-002-feat-skip-permissions-option-plan.md | 185 ++++++++++++ internal/claude/launch.go | 24 +- internal/session/terminal.go | 85 ++++-- internal/tui/dashboard/model.go | 268 ++++++++++-------- internal/tui/dashboard/newtask.go | 70 +++-- 8 files changed, 648 insertions(+), 176 deletions(-) create mode 100644 docs/brainstorms/skip-permissions-requirements.md create mode 100644 docs/brainstorms/warp-terminal-support-requirements.md create mode 100644 docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md create mode 100644 docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md diff --git a/docs/brainstorms/skip-permissions-requirements.md b/docs/brainstorms/skip-permissions-requirements.md new file mode 100644 index 0000000..7582654 --- /dev/null +++ b/docs/brainstorms/skip-permissions-requirements.md @@ -0,0 +1,43 @@ +# Skip Permissions Option + +**Date:** 2026-04-16 +**Status:** Ready for planning +**Scope:** Lightweight + +## Problem + +When launching Claude Code through `work-cli`, Claude runs with default permission behavior — it prompts the user to approve tool calls. Power users who trust their worktree isolation setup want the option to run with `--dangerously-skip-permissions`, but currently there's no way to pass this flag through the CLI. + +## Goal + +Users can opt into `--dangerously-skip-permissions` each time they launch Claude, both when creating a new task and when resuming an existing one. + +## Requirements + +1. **New field on `LaunchConfig`**: Add `SkipPermissions bool` to `internal/claude/launch.go:LaunchConfig`. Default `false`. + +2. **Prompt in new-task wizard**: Add a new `stepSkipPermissions` step between `stepConfigRepo` and `stepCreating` in the dashboard overlay wizard (`internal/tui/dashboard/newtask.go`). Show a Huh Select with "No (default)" and "Yes" options. Store the result on `newTaskModel` and thread it through `newTaskCreatedMsg` to `launchNewTask()`. Note: `internal/tui/newtask.go` is dead code with no callers — the live flow is the dashboard overlay. + +3. **Prompt on resume**: When resuming a task via the dashboard (`r` key, `internal/tui/dashboard/model.go:resumeTask`), show a Huh overlay prompt before launching Claude. Use the same overlay pattern as the new-task wizard (a small Huh form model rendered over the dashboard). The resume path builds the Claude command inline rather than calling `SpawnInTab`, so the flag needs to be threaded through that inline builder. + +4. **Flag passed to Claude**: When `SkipPermissions` is true, append `--dangerously-skip-permissions` to the Claude args in `SpawnInTab()` in `internal/claude/launch.go`, and in the inline command builders in `model.go:resumeTask` and `model.go:launchNewTask` (both build Claude commands inline rather than calling `SpawnInTab`). + +5. **Mutually exclusive with plan mode**: In `SpawnInTab()` only (the only place `--permission-mode plan` is passed), when `SkipPermissions` is true, do not also pass `--permission-mode plan`. The other command builders (`resumeTask`, `launchNewTask`) never pass plan mode, so no guard is needed there. + +6. **Cancel aborts the flow**: Pressing Esc on the skip-permissions prompt aborts the entire new-task or resume operation (matching how Esc works on every other wizard step). The user must explicitly select "No" or "Yes" to proceed. + +7. **Interactive prompt only**: `SkipPermissions` must only be set from interactive TUI prompts, never from environment variables, config files, or programmatic callers. + +## Non-goals + +- Persisting the user's preference across sessions (always prompt fresh) +- Adding a CLI flag to bypass the prompt (e.g., `work --skip-permissions`) +- Changing behavior of the attach flow (`a` key) — that focuses an existing session, not launching a new one + +## Touch Points + +| File | Change | +|------|--------| +| `internal/claude/launch.go` | Add `SkipPermissions` to `LaunchConfig`; thread into `SpawnInTab()` args | +| `internal/tui/dashboard/newtask.go` | Add skip-permissions prompt step in the dashboard new-task overlay wizard; pass value through to launch | +| `internal/tui/dashboard/model.go` | Add skip-permissions prompt in `resumeTask()` before building Claude command; thread into args in both `resumeTask()` and `launchNewTask()` inline builders | diff --git a/docs/brainstorms/warp-terminal-support-requirements.md b/docs/brainstorms/warp-terminal-support-requirements.md new file mode 100644 index 0000000..d37c52f --- /dev/null +++ b/docs/brainstorms/warp-terminal-support-requirements.md @@ -0,0 +1,39 @@ +# Warp Terminal Support + +**Date:** 2026-04-16 +**Status:** Ready for planning +**Scope:** Lightweight + +## Problem + +`work-cli` manages parallel Claude Code sessions by opening new terminal tabs and focusing existing ones. It currently supports Ghostty, iTerm2, and Terminal.app. Users running Warp terminal fall through to the `fallbackOpener`, which prints the command for manual execution — breaking the core UX of launching and resuming tasks from the dashboard. + +## Goal + +Warp users should get the same tab-launch and tab-focus experience that Ghostty users have today. + +## Requirements + +1. **Detection**: When `TERM_PROGRAM=WarpTerminal`, `DetectTerminal()` returns a `warpOpener`. +2. **OpenTab**: Opens a new Warp tab and executes the provided command. Uses AppleScript UI scripting via System Events (Cmd+T to open tab, keystroke to enter command), same pattern as `ghosttyOpener`. Sets tab title via ANSI escape (`\033]0;title\007`). +3. **FocusTab**: Activates Warp and cycles through tabs checking window title for the identifier, same approach as Ghostty's `FocusTab`. +4. **Command file pattern**: Uses the existing `writeCommandFile()` helper to avoid AppleScript escaping issues. + +## Non-goals + +- Warp CLI cold-start (no `-e` flag equivalent exists) +- Warp URI scheme integration (`warp://action/new_tab` can't execute commands) +- Warp Tab Configs (TOML files can't be triggered programmatically) +- Adding tests (no test files exist in the project today) + +## Approach + +Mirror the `ghosttyOpener` implementation with Warp-specific adjustments: +- Process name in System Events: `"Warp"` +- No CLI fallback — AppleScript-only (fallback to `fallbackOpener` behavior if Warp isn't running is acceptable) +- Tab cycling for `FocusTab` uses the same Cmd+Shift+] pattern + +## Known Limitations + +- **Focus steal on launch**: Warp activates (comes to foreground) when opening a tab, same as Ghostty. iTerm2 avoids this via its native scripting API. +- **Tab focus is approximate**: Cycling through tabs is visible to the user. Works but less polished than iTerm2's direct tab selection. diff --git a/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md b/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md new file mode 100644 index 0000000..3b22967 --- /dev/null +++ b/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md @@ -0,0 +1,110 @@ +--- +title: "feat: Add Warp terminal support" +type: feat +status: active +date: 2026-04-16 +origin: docs/brainstorms/warp-terminal-support-requirements.md +--- + +# feat: Add Warp terminal support + +## Overview + +Add a `warpOpener` implementation of the `TabOpener` interface so that Warp terminal users get the same tab-launch and tab-focus experience currently available to Ghostty, iTerm2, and Terminal.app users. The implementation mirrors `ghosttyOpener`'s AppleScript UI scripting approach since Warp lacks a native AppleScript dictionary. + +## Problem Frame + +Users running Warp terminal (`TERM_PROGRAM=WarpTerminal`) fall through to the `fallbackOpener`, which prints the command for manual copy-paste execution. This breaks the core UX of launching and resuming Claude Code sessions directly from the dashboard. (see origin: `docs/brainstorms/warp-terminal-support-requirements.md`) + +## Requirements Trace + +- R1. **Detection**: `DetectTerminal()` returns a `warpOpener` when `TERM_PROGRAM=WarpTerminal` +- R2. **OpenTab**: Opens a new Warp tab and executes the provided command via AppleScript UI scripting. Sets tab title via ANSI escape. +- R3. **FocusTab**: Activates Warp and cycles through tabs to find one matching the identifier. +- R4. **Command file pattern**: Uses existing `writeCommandFile()` helper to avoid AppleScript escaping issues. + +## Scope Boundaries + +- No Warp CLI cold-start fallback (no `-e` flag equivalent exists) +- No Warp URI scheme integration (`warp://action/new_tab` cannot execute commands) +- No Warp Tab Configs integration (cannot be triggered programmatically) +- No new test files (matching existing project convention) + +## Context & Research + +### Relevant Code and Patterns + +- `internal/session/terminal.go` — all terminal handlers live here, `TabOpener` interface defined at line 35 +- `ghosttyOpener` (lines 57-166) — direct template for the Warp implementation; uses System Events UI scripting for both `OpenTab` and `FocusTab` +- `writeCommandFile()` (lines 13-32) — shared helper that writes commands to temp scripts to avoid AppleScript escaping issues +- `DetectTerminal()` (lines 42-54) — switch on `strings.ToLower(os.Getenv("TERM_PROGRAM"))` + +### Institutional Learnings + +- No `docs/solutions/` directory exists in this project + +## Key Technical Decisions + +- **AppleScript-only (no CLI fallback)**: Ghostty has a CLI fallback via `ghostty -e`. Warp has no equivalent. If Warp is not running, `OpenTab` returns an error like the other handlers. This is acceptable because the tool is run from within a terminal — Warp will always be running when `TERM_PROGRAM=WarpTerminal` is set. +- **Mirror Ghostty's FocusTab approach**: Cycle through tabs using Cmd+Shift+] and check window title. Same limitation as Ghostty (visible cycling, max 20 tabs). +- **Process name in System Events**: Use `"Warp"` — this is the process name macOS reports for the Warp application. +- **ANSI title escape**: Use `printf '\033]0;title\007'` prefix in the command, same as Ghostty, to set the tab title for later `FocusTab` identification. + +## Implementation Units + +- [ ] **Unit 1: Add warpOpener struct and wire into DetectTerminal** + + **Goal:** Add a complete `warpOpener` implementing `TabOpener` and make it discoverable via `DetectTerminal()`. + + **Requirements:** R1, R2, R3, R4 + + **Dependencies:** None + + **Files:** + - Modify: `internal/session/terminal.go` + + **Approach:** + - Add `case "warpterminal":` to the `DetectTerminal()` switch (the env var value `WarpTerminal` becomes `warpterminal` after `strings.ToLower()`) + - Add `warpOpener` struct with `OpenTab` and `FocusTab` methods + - `OpenTab`: Follows `ghosttyOpener.openViaAppleScript` pattern — call `writeCommandFile()`, build AppleScript that activates Warp via System Events, sends Cmd+T for new tab, types the command, presses Return. Prepend ANSI title escape to command like Ghostty does. + - `FocusTab`: Follows `ghosttyOpener.FocusTab` pattern — activate Warp, cycle tabs with Cmd+Shift+] checking window title for the identifier. + - No CLI fallback method needed (unlike Ghostty's two-pronged approach). + - Place the new code between `ghosttyOpener` and `iterm2Opener` sections for logical grouping (UI-scripted terminals together). + + **Patterns to follow:** + - `ghosttyOpener.openViaAppleScript()` for `OpenTab` structure + - `ghosttyOpener.FocusTab()` for tab cycling approach + - Error wrapping with `fmt.Errorf("context: %w", err)` per CLAUDE.md conventions + - Cleanup function call on AppleScript failure, same as all other handlers + + **Test scenarios:** + - Test expectation: none — matching existing project convention (no test files exist) + + **Verification:** + - `go vet ./...` passes + - `go build ./cmd/work/` succeeds + - Manual test in Warp: creating a new task from the dashboard opens a new Warp tab and executes Claude in it + - Manual test in Warp: attaching to an existing session focuses the correct tab + - Running in a non-Warp terminal still works as before (no regression) + +## System-Wide Impact + +- **Interaction graph:** No new callbacks or middleware. The change adds a new case to an existing switch statement and a new struct implementing an existing interface. +- **Error propagation:** Follows the same pattern as all existing handlers — errors from AppleScript execution are wrapped and returned to callers in `claude/launch.go` and `tui/dashboard/model.go`. +- **API surface parity:** All four call sites (`SpawnInTab` in `launch.go`, `resumeTask` and `launchNewTask` in `model.go`, `Attach` in `attach.go`) use `DetectTerminal()` and automatically pick up the new handler. +- **Unchanged invariants:** Session tracking via PID files is terminal-agnostic and unaffected. The `TabOpener` interface is unchanged. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Warp's System Events process name may differ from `"Warp"` on some installs | Manual testing will verify; the name is standard for macOS app bundles | +| Warp may handle Cmd+Shift+] differently for tab cycling | Using the same key code (30) as Ghostty; standard macOS tab navigation shortcut | +| Warp's input block model may interfere with keystroke injection | Warp processes input differently from traditional terminals (blocks, AI suggestions). Manual testing should verify that System Events keystrokes are received by the shell prompt and that Warp features like AI command suggestions do not intercept the typed command | +| ANSI title escape may not set Warp's window title as reported by System Events | Manual testing will verify; Warp documents support for OSC title sequences. If Warp overrides the OSC 0 title with its own auto-generated title, a fallback is to check window content rather than title, or adjust the ANSI escape variant | + +## Sources & References + +- **Origin document:** [docs/brainstorms/warp-terminal-support-requirements.md](docs/brainstorms/warp-terminal-support-requirements.md) +- Related code: `internal/session/terminal.go` (all existing handlers) +- Warp TERM_PROGRAM value: confirmed as `WarpTerminal` by user diff --git a/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md b/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md new file mode 100644 index 0000000..969a5e1 --- /dev/null +++ b/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md @@ -0,0 +1,185 @@ +--- +title: "feat: Add --dangerously-skip-permissions option to Claude launches" +type: feat +status: active +date: 2026-04-16 +origin: docs/brainstorms/skip-permissions-requirements.md +--- + +# feat: Add --dangerously-skip-permissions option to Claude launches + +## Overview + +Add an interactive prompt to both the new-task wizard and resume flow that lets users opt into launching Claude Code with `--dangerously-skip-permissions`. The flag is never set programmatically — always gated behind an explicit TUI selection. + +## Problem Frame + +When launching Claude Code through `work-cli`, Claude runs with default permission behavior — it prompts the user to approve tool calls. Power users who trust their worktree isolation setup want the option to run with `--dangerously-skip-permissions`, but currently there's no way to pass this flag through the CLI. (see origin: `docs/brainstorms/skip-permissions-requirements.md`) + +## Requirements Trace + +- R1. Add `SkipPermissions bool` to `LaunchConfig`, default `false` +- R2. New `stepSkipPermissions` step in the dashboard new-task wizard between `stepConfigRepo` and `stepCreating` +- R3. Huh overlay prompt on resume before launching Claude +- R4. Append `--dangerously-skip-permissions` to Claude args in `SpawnInTab()`, `resumeTask()`, and `launchNewTask()` +- R5. Mutually exclusive with `--permission-mode plan` in `SpawnInTab()` only +- R6. Esc on the prompt aborts the entire flow +- R7. `SkipPermissions` must only be set from interactive TUI prompts + +## Scope Boundaries + +- No persisted preference — always prompt fresh +- No CLI flag (e.g., `work --skip-permissions`) +- No change to attach flow (`a` key) — that focuses an existing session +- No changes to `Exec()` or `Launch()` — dead code with no callers +- No changes to `root.go` comment/review `SpawnInTab()` callers — they don't prompt interactively, and `SkipPermissions` defaults to `false` + +## Context & Research + +### Relevant Code and Patterns + +- `internal/claude/launch.go:19-28` — `LaunchConfig` struct; `SpawnInTab()` at line 320 handles `PlanMode` at lines 355-357 +- `internal/tui/dashboard/newtask.go:29-35` — step enum (`stepPickRepos`, `stepTaskName`, `stepConfigRepo`, `stepCreating`, `stepDone`); `advanceStep()` at line 350 with three transitions from `stepConfigRepo` to `stepCreating` (lines ~389, ~407, ~443) +- `internal/tui/dashboard/model.go:623-706` — `resumeTask()` inline command builder; `launchNewTask()` at line 1500 reads `m.newTaskView` fields directly +- `internal/tui/dashboard/model.go:42-43` — overlay pattern: `newTaskView *newTaskModel` + `showNewTask bool`; `View()` renders overlay fullscreen at line 1364 +- `internal/tui/dashboard/commands.go:222-226` — `newTaskCreatedMsg` carries worktree results; `launchNewTask()` reads skip-permissions from `m.newTaskView` directly (no need to thread through this message) +- Huh form init pattern: `huh.NewForm(huh.NewGroup(...)).WithTheme(ui.HuhTheme()).WithWidth(m.formWidth()).WithShowHelp(true)` + +## Key Technical Decisions + +- **Overlay for resume (not confirming pattern)**: The `confirming` pattern (y/n in status bar) only supports simple yes/no. The overlay pattern (Huh form rendered fullscreen) matches the requirement for a Select form and is consistent with the new-task wizard aesthetic. (see origin) +- **New step constant (not appended to last config step)**: Adding `stepSkipPermissions` as a discrete step keeps concerns separated and matches the existing step-per-form pattern. (see origin) +- **Skip `Exec()` entirely**: `Exec()` and `Launch()` are dead code with no callers. Adding the flag there would perpetuate dead code. +- **`launchNewTask()` reads from `newTaskModel` directly**: No need to modify `newTaskCreatedMsg` — `launchNewTask()` already accesses `m.newTaskView.taskName` and `m.newTaskView.createdDirs` directly. Same pattern for `skipPermissions`. +- **Resume overlay stores pending task**: The resume skip-permissions overlay needs to remember which task is being resumed. Store a `*service.TaskView` on the overlay model so the resume can proceed after selection. + +## Implementation Units + +- [ ] **Unit 1: Add SkipPermissions to LaunchConfig and SpawnInTab** + + **Goal:** Establish the data model and wire the flag into the `SpawnInTab()` arg builder, mutually exclusive with `PlanMode`. + + **Requirements:** R1, R4, R5 + + **Dependencies:** None + + **Files:** + - Modify: `internal/claude/launch.go` + + **Approach:** + - Add `SkipPermissions bool` field to the `LaunchConfig` struct + - In `SpawnInTab()`, restructure the `PlanMode` block (lines 355-357) into an if/else-if: if `SkipPermissions`, append `--dangerously-skip-permissions`; else if `PlanMode`, append `--permission-mode plan` + - No changes to `Exec()` — it's dead code + + **Patterns to follow:** + - Existing `PlanMode` handling in `SpawnInTab()` at lines 355-357 + - `LaunchConfig` field ordering convention (group related booleans) + + **Test scenarios:** + - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. + + **Verification:** + - `go build ./...` succeeds + - Grep for `SkipPermissions` confirms it appears in `LaunchConfig` and `SpawnInTab()` + +- [ ] **Unit 2: Add stepSkipPermissions to new-task wizard and thread to launchNewTask** + + **Goal:** Add a skip-permissions Select prompt as a new step in the dashboard new-task wizard, and thread the result into the `launchNewTask()` inline command builder. + + **Requirements:** R2, R4, R6, R7 + + **Dependencies:** Unit 1 + + **Files:** + - Modify: `internal/tui/dashboard/newtask.go` + - Modify: `internal/tui/dashboard/model.go` + + **Approach:** + - In `newtask.go`: + - Add `stepSkipPermissions` to the step enum between `stepConfigRepo` and `stepCreating` + - Add `skipPermissions bool` and `skipPermsChoice string` fields to `newTaskModel` + - Create `initSkipPermissions()` method following `initTaskName()` pattern — a `huh.NewForm` with a `huh.NewSelect[string]` with Title `"Skip permission prompts?"` and Description `"⚠ Dangerous: Claude will execute tools without asking for approval. Use with caution."`, offering `huh.NewOption("No (default)", "no")` and `huh.NewOption("Yes — skip all permission checks", "yes")`, bound to `m.skipPermsChoice` (initialized to `"no"`), themed with `ui.HuhTheme()`. Use the same Title and Description for the resume overlay prompt. + - In `advanceStep()`, change the three transitions from `stepConfigRepo → stepCreating` to `stepConfigRepo → stepSkipPermissions` + - Also in `initConfigRepo()` (line ~194), change the fourth transition where all repos auto-configure in resume-from-PR mode (`m.step = stepCreating`) to `stepSkipPermissions` instead + - Add a `stepSkipPermissions` case in `advanceStep()`: read `m.skipPermsChoice`, set `m.skipPermissions = (choice == "yes")`, transition to `stepCreating` + - Add `stepSkipPermissions` rendering to `view()` (if needed — confirm the existing form-rendering path handles it) + - In `model.go`: + - In `launchNewTask()` (line ~1536), after building the args slice, add: if `m.newTaskView.skipPermissions`, append `--dangerously-skip-permissions` to args + + **Patterns to follow:** + - `initTaskName()` in `newtask.go` (lines 139-155) — form creation and binding pattern + - `stepConfigRepo` case in `advanceStep()` — transition logic + - `launchNewTask()` reading `m.newTaskView.createdDirs` and `m.newTaskView.taskName` directly + + **Test scenarios:** + - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. + + **Verification:** + - `go build ./...` succeeds + - Create a new task from the dashboard → after branch config, the skip-permissions Select appears + - Selecting "No (default)" → Claude launches without `--dangerously-skip-permissions` + - Selecting "Yes" → Claude launches with `--dangerously-skip-permissions` + - Pressing Esc on the prompt → returns to dashboard, no task created + +- [ ] **Unit 3: Add skip-permissions overlay for resume flow and thread to resumeTask** + + **Goal:** Add a Huh overlay prompt before resume launches Claude, and thread the result into the `resumeTask()` inline command builder. + + **Requirements:** R3, R4, R6, R7 + + **Dependencies:** Unit 1 + + **Files:** + - Modify: `internal/tui/dashboard/model.go` + + **Approach:** + - Define a small overlay model struct (e.g., `skipPermsPrompt`) in `model.go` with: + - `form *huh.Form` + - `choice string` (bound to the Select) + - `task *service.TaskView` (the pending task to resume) + - `init()` method that creates the Huh form (same Select as the new-task wizard) + - `update(msg)` method that delegates to `m.form.Update(msg)` + - `view()` method that renders the form + - Add `resumePrompt *skipPermsPrompt` and `showResumePrompt bool` fields to the dashboard `Model` struct + - Refactor `resumeTask()` into two parts: (1) `resumeTask()` now only checks for active session (attach flow) then shows the overlay and returns; (2) a new `executeResume(task, skipPermissions)` method contains the existing Prepare + command-building + spawn logic with the flag threaded in. This keeps Prepare() after the user's choice — no wasted work on cancel. + - Note: `handleResumeFromPR()` at model.go:727 also calls `resumeTask()` for the "matching task exists" case — this path will also show the overlay, which is correct (same prompt on every Claude launch). + - Define a new message type (e.g., `resumeSkipPermsMsg`) carrying the choice and task reference + - In `Update()`, handle `resumeSkipPermsMsg`: read the choice, then call `executeResume(task, skipPermissions)` + - In `Update()`, handle form abort (Esc): set `m.showResumePrompt = false`, return to dashboard + - In `View()`, when `m.showResumePrompt` is true, render the overlay fullscreen (same pattern as `showNewTask`) + - In `handleKey()`, when `m.showResumePrompt` is true, delegate to the overlay's update + + **Patterns to follow:** + - `newTaskView` / `showNewTask` overlay lifecycle in `model.go` (lines 42-43, 259-263, 271-273, 1364-1366) + - `newTaskFormCancelMsg` handling (lines 237-239) + - `resumeTask()` existing command-building logic (lines 655-669) — preserve, just add the flag + + **Test scenarios:** + - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. + + **Verification:** + - `go build ./...` succeeds + - Press `r` on an existing task → skip-permissions overlay appears + - Selecting "No (default)" → Claude resumes without `--dangerously-skip-permissions` + - Selecting "Yes" → Claude resumes with `--dangerously-skip-permissions` + - Pressing Esc → returns to dashboard, no Claude session launched + - Resume on a task with an active session → still focuses existing window (attach flow unchanged) + +## System-Wide Impact + +- **Interaction graph:** The skip-permissions flag flows through three independent command builders (`SpawnInTab`, `resumeTask` inline, `launchNewTask` inline). Changes to arg construction in one must be mirrored in the others. +- **API surface parity:** The `root.go` callers of `SpawnInTab()` (comment/review launches) are unaffected — they don't set `SkipPermissions` and get the `false` zero-value default. +- **Unchanged invariants:** The attach flow (`a` key / `handleAttach`) is unchanged. Session tracking, worktree creation, and PR workflows are all unaffected. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Step enum iota shift (adding `stepSkipPermissions` changes numeric values of `stepCreating` and `stepDone`) | All comparisons use named constants, not numeric values — safe. Verify with grep. | +| Resume overlay adds complexity to the dashboard model | Keep the overlay model minimal (single form, single message type). Follow the established `newTaskView` pattern exactly. | +| Three independent command builders must stay in sync | Flag handling is a single `if` + `append` in each — hard to get wrong. Verified by manual testing. | + +## Sources & References + +- **Origin document:** [docs/brainstorms/skip-permissions-requirements.md](docs/brainstorms/skip-permissions-requirements.md) +- Related code: `internal/claude/launch.go` (LaunchConfig, SpawnInTab), `internal/tui/dashboard/newtask.go` (wizard steps), `internal/tui/dashboard/model.go` (resume + launch flows) diff --git a/internal/claude/launch.go b/internal/claude/launch.go index ac4bd74..d034dd9 100644 --- a/internal/claude/launch.go +++ b/internal/claude/launch.go @@ -16,15 +16,20 @@ import ( ) // LaunchConfig holds the parameters for launching Claude. +// +// PlanMode and SkipPermissions are mutually exclusive; if both are true, +// SpawnInTab prefers PlanMode (the safer of the two) and ignores +// SkipPermissions. Callers should set at most one. type LaunchConfig struct { - Workspace *workspace.Workspace - TaskName string - Dirs []string // Worktree directories (all passed via --add-dir) - Comment *CommentContext // optional: PR review comment context - InitialPrompt string // optional: initial user message passed via positional arg - PlanMode bool // if true, launch with --permission-mode plan - ReviewMode bool // if true, launch for PR review exploration (no plan mode) - ReviewCtx *ReviewContext // optional: selected diff lines + PR context + Workspace *workspace.Workspace + TaskName string + Dirs []string // Worktree directories (all passed via --add-dir) + Comment *CommentContext // optional: PR review comment context + InitialPrompt string // optional: initial user message passed via positional arg + PlanMode bool // if true, launch with --permission-mode plan + SkipPermissions bool // if true, launch with --dangerously-skip-permissions + ReviewMode bool // if true, launch for PR review exploration (no plan mode) + ReviewCtx *ReviewContext // optional: selected diff lines + PR context } // CommentContext holds context for launching Claude to address a PR review comment. @@ -352,8 +357,11 @@ func SpawnInTab(cfg LaunchConfig) error { args = append(args, "--add-dir", d) } args = append(args, "--append-system-prompt", fmt.Sprintf("%q", systemPrompt)) + // Mutually exclusive: PlanMode wins if both are set, per LaunchConfig docs. if cfg.PlanMode { args = append(args, "--permission-mode", "plan") + } else if cfg.SkipPermissions { + args = append(args, "--dangerously-skip-permissions") } if cfg.InitialPrompt != "" { args = append(args, fmt.Sprintf("%q", cfg.InitialPrompt)) diff --git a/internal/session/terminal.go b/internal/session/terminal.go index dc5571a..1fbd179 100644 --- a/internal/session/terminal.go +++ b/internal/session/terminal.go @@ -168,11 +168,49 @@ end tell } // warpOpener opens tabs in Warp via AppleScript UI scripting (System Events). -// Warp has no AppleScript dictionary and no CLI launcher flag (unlike Ghostty's -e), -// so there is no cold-start fallback. If Warp is not running, OpenTab returns an error. -// In practice this is fine: TERM_PROGRAM=WarpTerminal is only set inside a running Warp session. +// Warp has no AppleScript dictionary and no CLI launcher flag (unlike Ghostty's +// -e), so OpenTab requires a running Warp instance. TERM_PROGRAM=WarpTerminal +// is only set inside a running Warp session, so callers will almost always +// have one, but we still surface a clear error if the process disappears. type warpOpener struct{} +// warpActivateScript polls for Warp to become frontmost before sending +// keystrokes. A fixed `delay` is racy — under load, keystrokes can arrive +// before Warp has focus and get sent to the wrong app. The poll caps at ~2s; +// after that AppleScript errors out rather than silently dropping input. +const warpActivateScript = ` +tell application "System Events" + if not (exists process "Warp") then + error "Warp not running" + end if + tell process "Warp" to set frontmost to true + set waited to 0 + repeat until (frontmost of process "Warp") or waited >= 20 + delay 0.1 + set waited to waited + 1 + end repeat + if not (frontmost of process "Warp") then + error "Warp did not come to front" + end if +end tell` + +// interpretWarpOsaError converts osascript output into a targeted error. +// macOS emits -1719 ("not allowed assistive access") when the calling terminal +// lacks Accessibility permission; without this check the user sees only a +// cryptic "osascript: exit status 1". +func interpretWarpOsaError(out []byte, err error) error { + s := string(out) + if strings.Contains(s, "-1719") || strings.Contains(strings.ToLower(s), "not allowed assistive access") { + return fmt.Errorf( + "Warp tab open blocked: the terminal running `work` needs Accessibility permission. " + + "Open System Settings → Privacy & Security → Accessibility and enable your terminal app, then retry") + } + if trimmed := strings.TrimSpace(s); trimmed != "" { + return fmt.Errorf("Warp AppleScript failed: %w: %s", err, trimmed) + } + return fmt.Errorf("Warp AppleScript failed: %w", err) +} + func (o *warpOpener) OpenTab(command, title string) (int, error) { fullCmd := fmt.Sprintf("printf '\\033]0;%s\\007' && %s", title, command) @@ -181,50 +219,31 @@ func (o *warpOpener) OpenTab(command, title string) (int, error) { return 0, fmt.Errorf("Warp command file: %w", err) } - script := fmt.Sprintf(` + script := fmt.Sprintf(`%s tell application "System Events" - if not (exists process "Warp") then - error "Warp not running" - end if - tell process "Warp" - set frontmost to true - end tell + tell process "Warp" to keystroke "t" using command down end tell delay 0.3 -tell application "System Events" - tell process "Warp" - keystroke "t" using command down - end tell -end tell -delay 0.5 tell application "System Events" tell process "Warp" keystroke %q key code 36 end tell end tell -`, execCmd) +`, warpActivateScript, execCmd) cmd := exec.Command("osascript", "-e", script) - if err := cmd.Run(); err != nil { + out, err := cmd.CombinedOutput() + if err != nil { cleanup() // remove temp file since terminal never got the command - return 0, fmt.Errorf("Warp AppleScript failed: %w", err) + return 0, interpretWarpOsaError(out, err) } // temp file self-deletes via "rm -f" in the exec snippet return 0, nil } func (o *warpOpener) FocusTab(identifier string) error { - script := fmt.Sprintf(` -tell application "System Events" - if not (exists process "Warp") then - error "Warp not running" - end if - tell process "Warp" - set frontmost to true - end tell -end tell -delay 0.3 + script := fmt.Sprintf(`%s tell application "System Events" tell process "Warp" set maxTabs to 20 @@ -239,9 +258,13 @@ tell application "System Events" end repeat end tell end tell -`, identifier) +`, warpActivateScript, identifier) cmd := exec.Command("osascript", "-e", script) - return cmd.Run() + out, err := cmd.CombinedOutput() + if err != nil { + return interpretWarpOsaError(out, err) + } + return nil } // iterm2Opener opens tabs in iTerm2 via AppleScript. diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 1c9e43d..50034cd 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/tSquaredd/work-cli/internal/claude" "github.com/tSquaredd/work-cli/internal/service" @@ -42,6 +43,10 @@ type Model struct { newTaskView *newTaskModel showNewTask bool + // Resume skip-permissions overlay + resumePrompt *skipPermsPrompt + showResumePrompt bool + // Standalone PRs myPRs []service.StandalonePR otherPRs []service.StandalonePR @@ -93,6 +98,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.newTaskView.width = msg.Width m.newTaskView.height = msg.Height } + if m.showResumePrompt && m.resumePrompt != nil { + m.resumePrompt.width = msg.Width + m.resumePrompt.height = msg.Height + } return m, nil case tasksLoadedMsg: @@ -239,6 +248,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateStatusBar() return m, nil + case resumeSkipPermsMsg: + m.showResumePrompt = false + m.updateStatusBar() + return m.executeResume(msg.taskName, msg.skipPermissions) + case newTaskCreatedMsg: m.newTaskView.step = stepDone m.newTaskView.createdDirs = msg.dirs @@ -261,9 +275,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + // Forward messages to resume prompt overlay when active + if m.showResumePrompt && m.resumePrompt != nil { + return m.advanceResumePrompt(msg) + } + return m, nil } +// advanceResumePrompt forwards msg to the resume overlay form and handles +// completion/abort in one place. Callers from both Update and handleKey share +// this to avoid drifting behavior. +func (m Model) advanceResumePrompt(msg tea.Msg) (tea.Model, tea.Cmd) { + cmd := m.resumePrompt.update(msg) + switch m.resumePrompt.form.State { + case huh.StateCompleted: + taskName := m.resumePrompt.taskName + skipPerms := m.resumePrompt.skipPerms + m.showResumePrompt = false + return m, func() tea.Msg { + return resumeSkipPermsMsg{ + taskName: taskName, + skipPermissions: skipPerms, + } + } + case huh.StateAborted: + m.showResumePrompt = false + m.updateStatusBar() + return m, nil + } + return m, cmd +} + func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() @@ -272,6 +315,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleNewTaskKey(msg) } + // Handle resume skip-permissions overlay + if m.showResumePrompt && m.resumePrompt != nil { + if key == "esc" { + m.showResumePrompt = false + m.updateStatusBar() + return m, nil + } + return m.advanceResumePrompt(msg) + } + // Handle confirmation mode if m.confirming { return m.handleConfirmKey(msg) @@ -620,85 +673,113 @@ func (m Model) handleResume() (tea.Model, tea.Cmd) { return m.resumeTask(sel) } +// skipPermsPrompt is a lightweight overlay for the skip-permissions question on resume. +// Holds taskName rather than a *service.TaskView: the tasks slice is replaced on +// every refresh tick, so any pointer captured at open time goes stale. The task +// is re-resolved by name when the prompt is confirmed. +type skipPermsPrompt struct { + form *huh.Form + skipPerms bool + taskName string + width int + height int +} + +func newSkipPermsPrompt(taskName string, width, height int) (*skipPermsPrompt, tea.Cmd) { + p := &skipPermsPrompt{ + taskName: taskName, + width: width, + height: height, + } + p.form = buildSkipPermissionsForm(&p.skipPerms, min(width-4, 80)) + return p, p.form.Init() +} + +func (p *skipPermsPrompt) update(msg tea.Msg) tea.Cmd { + form, cmd := p.form.Update(msg) + p.form = form.(*huh.Form) + return cmd +} + +func (p *skipPermsPrompt) view() string { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ui.ColorPrimary). + MarginBottom(1) + + title := titleStyle.Render(fmt.Sprintf("Resume: %s", p.taskName)) + content := lipgloss.JoinVertical(lipgloss.Left, title, p.form.View(), skipPermsHelpLine()) + return lipgloss.Place(p.width, p.height, lipgloss.Center, lipgloss.Center, content) +} + +// resumeSkipPermsMsg carries the result of the skip-permissions prompt for resume. +type resumeSkipPermsMsg struct { + taskName string + skipPermissions bool +} + +// findTaskByName returns the current TaskView for name, or nil if the task has +// disappeared (e.g. cleaned up during the prompt). +func (m Model) findTaskByName(name string) *service.TaskView { + for i := range m.taskList.tasks { + if m.taskList.tasks[i].Name == name { + return &m.taskList.tasks[i] + } + } + return nil +} + func (m Model) resumeTask(sel *service.TaskView) (tea.Model, tea.Cmd) { // If session is already active, focus the existing window if sel.HasSession { return m.handleAttach() } - // Build the claude command to run in a new tab dirs := sel.Dirs() if len(dirs) == 0 { return m, nil } - // Prepare claude files - cfg := claude.LaunchConfig{ - Workspace: m.svc.Workspace, - TaskName: sel.Name, - Dirs: dirs, - } - - if err := claude.Prepare(cfg); err != nil { - m.statusBar.message = fmt.Sprintf("Error preparing: %s", err) - return m, clearMessageCmd() - } + // Show skip-permissions overlay before launching + prompt, cmd := newSkipPermsPrompt(sel.Name, m.width, m.height) + m.resumePrompt = prompt + m.showResumePrompt = true + return m, cmd +} - // Build command string - claudePath, err := exec.LookPath("claude") - if err != nil { - m.statusBar.message = "claude not found in PATH" +func (m Model) executeResume(taskName string, skipPermissions bool) (tea.Model, tea.Cmd) { + // Re-resolve the task by name: the tasks slice is replaced on every refresh + // tick, and the task may have been cleaned up or had a session started while + // the prompt was open. + task := m.findTaskByName(taskName) + if task == nil { + m.statusBar.message = fmt.Sprintf("Task %q no longer exists", taskName) return m, clearMessageCmd() } - - systemPrompt := claude.BuildSystemPrompt(cfg) - var cmdParts []string - // Single repo: launch in the repo's worktree directory. - // Multiple repos: launch in the workspace root. - launchDir := m.svc.Workspace.Root - if len(dirs) == 1 { - launchDir = dirs[0] + if task.HasSession { + // A session appeared while the prompt was open — attach instead of + // spawning a duplicate. + return m.handleAttach() } - cmdParts = append(cmdParts, fmt.Sprintf("cd %q", launchDir)) - args := []string{claudePath} - for _, d := range dirs { - args = append(args, "--add-dir", d) + dirs := task.Dirs() + if len(dirs) == 0 { + return m, nil } - args = append(args, "--append-system-prompt", fmt.Sprintf("%q", systemPrompt)) - cmdParts = append(cmdParts, strings.Join(args, " ")) - - command := strings.Join(cmdParts, " && ") - // Wrap command with shell PID tracking - if m.svc.Tracker != nil { - command = m.svc.Tracker.WrapCommand(sel.Name, command) + cfg := claude.LaunchConfig{ + Workspace: m.svc.Workspace, + TaskName: task.Name, + Dirs: dirs, + SkipPermissions: skipPermissions, } - tabTitle := "work: " + sel.Name - - // Spawn in new terminal tab - opener := session.DetectTerminal() - pid, err := opener.OpenTab(command, tabTitle) - if err != nil { - m.statusBar.message = fmt.Sprintf("Tab spawn failed: %s", err) + if err := claude.SpawnInTab(cfg); err != nil { + m.statusBar.message = fmt.Sprintf("Launch failed: %s", err) return m, clearMessageCmd() } - // Register session - if m.svc.Tracker != nil { - rec := session.SessionRecord{ - TaskName: sel.Name, - PID: pid, - Dirs: dirs, - LaunchedAt: time.Now(), - TerminalTab: tabTitle, - WorkspaceRoot: m.svc.Workspace.Root, - } - _ = m.svc.Tracker.Register(rec) - } - - m.statusBar.message = fmt.Sprintf("Launched %s in new tab", sel.Name) + m.statusBar.message = fmt.Sprintf("Launched %s in new tab", task.Name) return m, tea.Batch( loadTasks(m.svc), clearMessageCmd(), @@ -1365,6 +1446,11 @@ func (m Model) View() string { return m.newTaskView.view() } + // Fullscreen resume prompt overlay + if m.showResumePrompt && m.resumePrompt != nil { + return m.resumePrompt.view() + } + // Fullscreen diff viewer overlay if m.showDiffView { return m.diffView.view() @@ -1501,78 +1587,20 @@ func (m Model) launchNewTask() (tea.Model, tea.Cmd) { dirs := m.newTaskView.createdDirs taskName := m.newTaskView.taskName - // Prepare claude files cfg := claude.LaunchConfig{ - Workspace: m.svc.Workspace, - TaskName: taskName, - Dirs: dirs, - } - - if err := claude.Prepare(cfg); err != nil { - m.statusBar.message = fmt.Sprintf("Error preparing: %s", err) - m.showNewTask = false - m.updateStatusBar() - return m, clearMessageCmd() - } - - // Build command string - claudePath, err := exec.LookPath("claude") - if err != nil { - m.statusBar.message = "claude not found in PATH" - m.showNewTask = false - m.updateStatusBar() - return m, clearMessageCmd() - } - - systemPrompt := claude.BuildSystemPrompt(cfg) - var cmdParts []string - // Single repo: launch in the repo's worktree directory. - // Multiple repos: launch in the workspace root. - launchDir := m.svc.Workspace.Root - if len(dirs) == 1 { - launchDir = dirs[0] - } - cmdParts = append(cmdParts, fmt.Sprintf("cd %q", launchDir)) - - args := []string{claudePath} - for _, d := range dirs { - args = append(args, "--add-dir", d) - } - args = append(args, "--append-system-prompt", fmt.Sprintf("%q", systemPrompt)) - cmdParts = append(cmdParts, strings.Join(args, " ")) - - command := strings.Join(cmdParts, " && ") - - // Wrap command with shell PID tracking - if m.svc.Tracker != nil { - command = m.svc.Tracker.WrapCommand(taskName, command) + Workspace: m.svc.Workspace, + TaskName: taskName, + Dirs: dirs, + SkipPermissions: m.newTaskView.skipPermissions, } - tabTitle := "work: " + taskName - - // Spawn in new terminal tab - opener := session.DetectTerminal() - pid, err := opener.OpenTab(command, tabTitle) - if err != nil { - m.statusBar.message = fmt.Sprintf("Tab spawn failed: %s", err) + if err := claude.SpawnInTab(cfg); err != nil { + m.statusBar.message = fmt.Sprintf("Launch failed: %s", err) m.showNewTask = false m.updateStatusBar() return m, clearMessageCmd() } - // Register session - if m.svc.Tracker != nil { - rec := session.SessionRecord{ - TaskName: taskName, - PID: pid, - Dirs: dirs, - LaunchedAt: time.Now(), - TerminalTab: tabTitle, - WorkspaceRoot: m.svc.Workspace.Root, - } - _ = m.svc.Tracker.Register(rec) - } - m.showNewTask = false m.statusBar.message = fmt.Sprintf("Launched %s in new tab", taskName) m.updateStatusBar() diff --git a/internal/tui/dashboard/newtask.go b/internal/tui/dashboard/newtask.go index 6b18947..496e23f 100644 --- a/internal/tui/dashboard/newtask.go +++ b/internal/tui/dashboard/newtask.go @@ -15,6 +15,32 @@ import ( "github.com/tSquaredd/work-cli/internal/workspace" ) +// buildSkipPermissionsForm creates the shared skip-permissions selection form. +// A Select is used instead of Confirm so that single-letter hotkeys (y/n) can't +// inadvertently bypass this security-sensitive prompt; the user must explicitly +// move the cursor and press enter. +func buildSkipPermissionsForm(value *bool, width int) *huh.Form { + field := huh.NewSelect[bool](). + Title("Skip permission prompts?"). + Description("⚠ Dangerous: Claude will execute tools without asking for approval. Use with caution."). + Options( + huh.NewOption("No (default)", false), + huh.NewOption("Yes — skip permissions", true), + ). + Value(value) + return huh.NewForm( + huh.NewGroup(field.WithHeight(8)), + ).WithTheme(ui.HuhTheme()). + WithWidth(width). + WithShowHelp(false) +} + +// skipPermsHelpLine returns a styled help line for the skip-permissions form. +func skipPermsHelpLine() string { + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorMuted) + return dimStyle.Render("↑ ↓ choose • enter submit • esc go back") +} + // repoConfig holds per-repo branch configuration for the new task wizard. type repoConfig struct { Alias string @@ -30,6 +56,7 @@ const ( stepPickRepos newTaskStep = iota stepTaskName stepConfigRepo + stepSkipPermissions stepCreating stepDone ) @@ -55,9 +82,10 @@ type newTaskModel struct { configIdx int // which repo we're configuring (branch + base) // Form value bindings (huh writes into these) - selectedAliases []string // bound to MultiSelect in stepPickRepos - curBranch string - curBaseBranch string + selectedAliases []string // bound to MultiSelect in stepPickRepos + curBranch string + curBaseBranch string + skipPermissions bool // bound to Confirm in stepSkipPermissions // Creation results createdDirs []string @@ -153,6 +181,13 @@ func (m *newTaskModel) initTaskName() tea.Cmd { return m.form.Init() } +func (m *newTaskModel) initSkipPermissions() tea.Cmd { + m.step = stepSkipPermissions + m.skipPermissions = false + m.form = buildSkipPermissionsForm(&m.skipPermissions, m.formWidth()) + return m.form.Init() +} + // initConfigRepo creates the branch config form for the current repo. func (m *newTaskModel) initConfigRepo() tea.Cmd { m.step = stepConfigRepo @@ -190,14 +225,9 @@ func (m *newTaskModel) initConfigRepo() tea.Cmd { } break // this repo needs manual config (Fix 1) } - // If all repos were auto-configured, jump to creation. + // If all repos were auto-configured, ask about permissions before creation. if m.configIdx >= len(m.selectedRepos) { - m.step = stepCreating - m.form = nil - return tea.Batch( - m.spinner.Tick, - createWorktrees(m.ws, m.taskName, m.configs), - ) + return m.initSkipPermissions() } } @@ -386,9 +416,7 @@ func (m *newTaskModel) advanceStep() tea.Cmd { if m.configIdx < len(m.selectedRepos) { return m.initConfigRepo() } - m.step = stepCreating - m.form = nil - return tea.Batch(m.spinner.Tick, createWorktrees(m.ws, m.taskName, m.configs)) + return m.initSkipPermissions() } // Handle the non-PR repo branch-switch form. @@ -405,9 +433,7 @@ func (m *newTaskModel) advanceStep() tea.Cmd { if m.configIdx < len(m.selectedRepos) { return m.initConfigRepo() } - m.step = stepCreating - m.form = nil - return tea.Batch(m.spinner.Tick, createWorktrees(m.ws, m.taskName, m.configs)) + return m.initSkipPermissions() } // Save config for current repo @@ -439,7 +465,10 @@ func (m *newTaskModel) advanceStep() tea.Cmd { return m.initConfigRepo() } - // All repos configured — start creation + // All repos configured — ask about permissions + return m.initSkipPermissions() + + case stepSkipPermissions: m.step = stepCreating m.form = nil return tea.Batch( @@ -514,6 +543,13 @@ func (m *newTaskModel) view() string { b.WriteString(m.form.View()) } + case stepSkipPermissions: + if m.form != nil { + b.WriteString(m.form.View()) + b.WriteString("\n") + b.WriteString(skipPermsHelpLine()) + } + case stepCreating: b.WriteString(m.spinner.View() + " Creating worktrees for " + ui.StyleInfo.Render(m.taskName) + "...\n") From 26af6e6cf63a8f6a7c74a75e3ab08f35e378949c Mon Sep 17 00:00:00 2001 From: davidsprague-udisc Date: Thu, 16 Apr 2026 18:36:52 -0400 Subject: [PATCH 2/2] chore: remove local design docs and gitignore docs/ These design docs are kept local-only and shouldn't have been committed. Add docs/ to .gitignore so they stay out of future PRs. --- .gitignore | 3 + .../skip-permissions-requirements.md | 43 ---- .../warp-terminal-support-requirements.md | 39 ---- ...-16-001-feat-warp-terminal-support-plan.md | 110 ----------- ...6-002-feat-skip-permissions-option-plan.md | 185 ------------------ 5 files changed, 3 insertions(+), 377 deletions(-) delete mode 100644 docs/brainstorms/skip-permissions-requirements.md delete mode 100644 docs/brainstorms/warp-terminal-support-requirements.md delete mode 100644 docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md delete mode 100644 docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md diff --git a/.gitignore b/.gitignore index b0b606e..6bab045 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ # OS .DS_Store + +# Local design docs — kept local, not tracked +docs/ diff --git a/docs/brainstorms/skip-permissions-requirements.md b/docs/brainstorms/skip-permissions-requirements.md deleted file mode 100644 index 7582654..0000000 --- a/docs/brainstorms/skip-permissions-requirements.md +++ /dev/null @@ -1,43 +0,0 @@ -# Skip Permissions Option - -**Date:** 2026-04-16 -**Status:** Ready for planning -**Scope:** Lightweight - -## Problem - -When launching Claude Code through `work-cli`, Claude runs with default permission behavior — it prompts the user to approve tool calls. Power users who trust their worktree isolation setup want the option to run with `--dangerously-skip-permissions`, but currently there's no way to pass this flag through the CLI. - -## Goal - -Users can opt into `--dangerously-skip-permissions` each time they launch Claude, both when creating a new task and when resuming an existing one. - -## Requirements - -1. **New field on `LaunchConfig`**: Add `SkipPermissions bool` to `internal/claude/launch.go:LaunchConfig`. Default `false`. - -2. **Prompt in new-task wizard**: Add a new `stepSkipPermissions` step between `stepConfigRepo` and `stepCreating` in the dashboard overlay wizard (`internal/tui/dashboard/newtask.go`). Show a Huh Select with "No (default)" and "Yes" options. Store the result on `newTaskModel` and thread it through `newTaskCreatedMsg` to `launchNewTask()`. Note: `internal/tui/newtask.go` is dead code with no callers — the live flow is the dashboard overlay. - -3. **Prompt on resume**: When resuming a task via the dashboard (`r` key, `internal/tui/dashboard/model.go:resumeTask`), show a Huh overlay prompt before launching Claude. Use the same overlay pattern as the new-task wizard (a small Huh form model rendered over the dashboard). The resume path builds the Claude command inline rather than calling `SpawnInTab`, so the flag needs to be threaded through that inline builder. - -4. **Flag passed to Claude**: When `SkipPermissions` is true, append `--dangerously-skip-permissions` to the Claude args in `SpawnInTab()` in `internal/claude/launch.go`, and in the inline command builders in `model.go:resumeTask` and `model.go:launchNewTask` (both build Claude commands inline rather than calling `SpawnInTab`). - -5. **Mutually exclusive with plan mode**: In `SpawnInTab()` only (the only place `--permission-mode plan` is passed), when `SkipPermissions` is true, do not also pass `--permission-mode plan`. The other command builders (`resumeTask`, `launchNewTask`) never pass plan mode, so no guard is needed there. - -6. **Cancel aborts the flow**: Pressing Esc on the skip-permissions prompt aborts the entire new-task or resume operation (matching how Esc works on every other wizard step). The user must explicitly select "No" or "Yes" to proceed. - -7. **Interactive prompt only**: `SkipPermissions` must only be set from interactive TUI prompts, never from environment variables, config files, or programmatic callers. - -## Non-goals - -- Persisting the user's preference across sessions (always prompt fresh) -- Adding a CLI flag to bypass the prompt (e.g., `work --skip-permissions`) -- Changing behavior of the attach flow (`a` key) — that focuses an existing session, not launching a new one - -## Touch Points - -| File | Change | -|------|--------| -| `internal/claude/launch.go` | Add `SkipPermissions` to `LaunchConfig`; thread into `SpawnInTab()` args | -| `internal/tui/dashboard/newtask.go` | Add skip-permissions prompt step in the dashboard new-task overlay wizard; pass value through to launch | -| `internal/tui/dashboard/model.go` | Add skip-permissions prompt in `resumeTask()` before building Claude command; thread into args in both `resumeTask()` and `launchNewTask()` inline builders | diff --git a/docs/brainstorms/warp-terminal-support-requirements.md b/docs/brainstorms/warp-terminal-support-requirements.md deleted file mode 100644 index d37c52f..0000000 --- a/docs/brainstorms/warp-terminal-support-requirements.md +++ /dev/null @@ -1,39 +0,0 @@ -# Warp Terminal Support - -**Date:** 2026-04-16 -**Status:** Ready for planning -**Scope:** Lightweight - -## Problem - -`work-cli` manages parallel Claude Code sessions by opening new terminal tabs and focusing existing ones. It currently supports Ghostty, iTerm2, and Terminal.app. Users running Warp terminal fall through to the `fallbackOpener`, which prints the command for manual execution — breaking the core UX of launching and resuming tasks from the dashboard. - -## Goal - -Warp users should get the same tab-launch and tab-focus experience that Ghostty users have today. - -## Requirements - -1. **Detection**: When `TERM_PROGRAM=WarpTerminal`, `DetectTerminal()` returns a `warpOpener`. -2. **OpenTab**: Opens a new Warp tab and executes the provided command. Uses AppleScript UI scripting via System Events (Cmd+T to open tab, keystroke to enter command), same pattern as `ghosttyOpener`. Sets tab title via ANSI escape (`\033]0;title\007`). -3. **FocusTab**: Activates Warp and cycles through tabs checking window title for the identifier, same approach as Ghostty's `FocusTab`. -4. **Command file pattern**: Uses the existing `writeCommandFile()` helper to avoid AppleScript escaping issues. - -## Non-goals - -- Warp CLI cold-start (no `-e` flag equivalent exists) -- Warp URI scheme integration (`warp://action/new_tab` can't execute commands) -- Warp Tab Configs (TOML files can't be triggered programmatically) -- Adding tests (no test files exist in the project today) - -## Approach - -Mirror the `ghosttyOpener` implementation with Warp-specific adjustments: -- Process name in System Events: `"Warp"` -- No CLI fallback — AppleScript-only (fallback to `fallbackOpener` behavior if Warp isn't running is acceptable) -- Tab cycling for `FocusTab` uses the same Cmd+Shift+] pattern - -## Known Limitations - -- **Focus steal on launch**: Warp activates (comes to foreground) when opening a tab, same as Ghostty. iTerm2 avoids this via its native scripting API. -- **Tab focus is approximate**: Cycling through tabs is visible to the user. Works but less polished than iTerm2's direct tab selection. diff --git a/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md b/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md deleted file mode 100644 index 3b22967..0000000 --- a/docs/plans/2026-04-16-001-feat-warp-terminal-support-plan.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "feat: Add Warp terminal support" -type: feat -status: active -date: 2026-04-16 -origin: docs/brainstorms/warp-terminal-support-requirements.md ---- - -# feat: Add Warp terminal support - -## Overview - -Add a `warpOpener` implementation of the `TabOpener` interface so that Warp terminal users get the same tab-launch and tab-focus experience currently available to Ghostty, iTerm2, and Terminal.app users. The implementation mirrors `ghosttyOpener`'s AppleScript UI scripting approach since Warp lacks a native AppleScript dictionary. - -## Problem Frame - -Users running Warp terminal (`TERM_PROGRAM=WarpTerminal`) fall through to the `fallbackOpener`, which prints the command for manual copy-paste execution. This breaks the core UX of launching and resuming Claude Code sessions directly from the dashboard. (see origin: `docs/brainstorms/warp-terminal-support-requirements.md`) - -## Requirements Trace - -- R1. **Detection**: `DetectTerminal()` returns a `warpOpener` when `TERM_PROGRAM=WarpTerminal` -- R2. **OpenTab**: Opens a new Warp tab and executes the provided command via AppleScript UI scripting. Sets tab title via ANSI escape. -- R3. **FocusTab**: Activates Warp and cycles through tabs to find one matching the identifier. -- R4. **Command file pattern**: Uses existing `writeCommandFile()` helper to avoid AppleScript escaping issues. - -## Scope Boundaries - -- No Warp CLI cold-start fallback (no `-e` flag equivalent exists) -- No Warp URI scheme integration (`warp://action/new_tab` cannot execute commands) -- No Warp Tab Configs integration (cannot be triggered programmatically) -- No new test files (matching existing project convention) - -## Context & Research - -### Relevant Code and Patterns - -- `internal/session/terminal.go` — all terminal handlers live here, `TabOpener` interface defined at line 35 -- `ghosttyOpener` (lines 57-166) — direct template for the Warp implementation; uses System Events UI scripting for both `OpenTab` and `FocusTab` -- `writeCommandFile()` (lines 13-32) — shared helper that writes commands to temp scripts to avoid AppleScript escaping issues -- `DetectTerminal()` (lines 42-54) — switch on `strings.ToLower(os.Getenv("TERM_PROGRAM"))` - -### Institutional Learnings - -- No `docs/solutions/` directory exists in this project - -## Key Technical Decisions - -- **AppleScript-only (no CLI fallback)**: Ghostty has a CLI fallback via `ghostty -e`. Warp has no equivalent. If Warp is not running, `OpenTab` returns an error like the other handlers. This is acceptable because the tool is run from within a terminal — Warp will always be running when `TERM_PROGRAM=WarpTerminal` is set. -- **Mirror Ghostty's FocusTab approach**: Cycle through tabs using Cmd+Shift+] and check window title. Same limitation as Ghostty (visible cycling, max 20 tabs). -- **Process name in System Events**: Use `"Warp"` — this is the process name macOS reports for the Warp application. -- **ANSI title escape**: Use `printf '\033]0;title\007'` prefix in the command, same as Ghostty, to set the tab title for later `FocusTab` identification. - -## Implementation Units - -- [ ] **Unit 1: Add warpOpener struct and wire into DetectTerminal** - - **Goal:** Add a complete `warpOpener` implementing `TabOpener` and make it discoverable via `DetectTerminal()`. - - **Requirements:** R1, R2, R3, R4 - - **Dependencies:** None - - **Files:** - - Modify: `internal/session/terminal.go` - - **Approach:** - - Add `case "warpterminal":` to the `DetectTerminal()` switch (the env var value `WarpTerminal` becomes `warpterminal` after `strings.ToLower()`) - - Add `warpOpener` struct with `OpenTab` and `FocusTab` methods - - `OpenTab`: Follows `ghosttyOpener.openViaAppleScript` pattern — call `writeCommandFile()`, build AppleScript that activates Warp via System Events, sends Cmd+T for new tab, types the command, presses Return. Prepend ANSI title escape to command like Ghostty does. - - `FocusTab`: Follows `ghosttyOpener.FocusTab` pattern — activate Warp, cycle tabs with Cmd+Shift+] checking window title for the identifier. - - No CLI fallback method needed (unlike Ghostty's two-pronged approach). - - Place the new code between `ghosttyOpener` and `iterm2Opener` sections for logical grouping (UI-scripted terminals together). - - **Patterns to follow:** - - `ghosttyOpener.openViaAppleScript()` for `OpenTab` structure - - `ghosttyOpener.FocusTab()` for tab cycling approach - - Error wrapping with `fmt.Errorf("context: %w", err)` per CLAUDE.md conventions - - Cleanup function call on AppleScript failure, same as all other handlers - - **Test scenarios:** - - Test expectation: none — matching existing project convention (no test files exist) - - **Verification:** - - `go vet ./...` passes - - `go build ./cmd/work/` succeeds - - Manual test in Warp: creating a new task from the dashboard opens a new Warp tab and executes Claude in it - - Manual test in Warp: attaching to an existing session focuses the correct tab - - Running in a non-Warp terminal still works as before (no regression) - -## System-Wide Impact - -- **Interaction graph:** No new callbacks or middleware. The change adds a new case to an existing switch statement and a new struct implementing an existing interface. -- **Error propagation:** Follows the same pattern as all existing handlers — errors from AppleScript execution are wrapped and returned to callers in `claude/launch.go` and `tui/dashboard/model.go`. -- **API surface parity:** All four call sites (`SpawnInTab` in `launch.go`, `resumeTask` and `launchNewTask` in `model.go`, `Attach` in `attach.go`) use `DetectTerminal()` and automatically pick up the new handler. -- **Unchanged invariants:** Session tracking via PID files is terminal-agnostic and unaffected. The `TabOpener` interface is unchanged. - -## Risks & Dependencies - -| Risk | Mitigation | -|------|------------| -| Warp's System Events process name may differ from `"Warp"` on some installs | Manual testing will verify; the name is standard for macOS app bundles | -| Warp may handle Cmd+Shift+] differently for tab cycling | Using the same key code (30) as Ghostty; standard macOS tab navigation shortcut | -| Warp's input block model may interfere with keystroke injection | Warp processes input differently from traditional terminals (blocks, AI suggestions). Manual testing should verify that System Events keystrokes are received by the shell prompt and that Warp features like AI command suggestions do not intercept the typed command | -| ANSI title escape may not set Warp's window title as reported by System Events | Manual testing will verify; Warp documents support for OSC title sequences. If Warp overrides the OSC 0 title with its own auto-generated title, a fallback is to check window content rather than title, or adjust the ANSI escape variant | - -## Sources & References - -- **Origin document:** [docs/brainstorms/warp-terminal-support-requirements.md](docs/brainstorms/warp-terminal-support-requirements.md) -- Related code: `internal/session/terminal.go` (all existing handlers) -- Warp TERM_PROGRAM value: confirmed as `WarpTerminal` by user diff --git a/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md b/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md deleted file mode 100644 index 969a5e1..0000000 --- a/docs/plans/2026-04-16-002-feat-skip-permissions-option-plan.md +++ /dev/null @@ -1,185 +0,0 @@ ---- -title: "feat: Add --dangerously-skip-permissions option to Claude launches" -type: feat -status: active -date: 2026-04-16 -origin: docs/brainstorms/skip-permissions-requirements.md ---- - -# feat: Add --dangerously-skip-permissions option to Claude launches - -## Overview - -Add an interactive prompt to both the new-task wizard and resume flow that lets users opt into launching Claude Code with `--dangerously-skip-permissions`. The flag is never set programmatically — always gated behind an explicit TUI selection. - -## Problem Frame - -When launching Claude Code through `work-cli`, Claude runs with default permission behavior — it prompts the user to approve tool calls. Power users who trust their worktree isolation setup want the option to run with `--dangerously-skip-permissions`, but currently there's no way to pass this flag through the CLI. (see origin: `docs/brainstorms/skip-permissions-requirements.md`) - -## Requirements Trace - -- R1. Add `SkipPermissions bool` to `LaunchConfig`, default `false` -- R2. New `stepSkipPermissions` step in the dashboard new-task wizard between `stepConfigRepo` and `stepCreating` -- R3. Huh overlay prompt on resume before launching Claude -- R4. Append `--dangerously-skip-permissions` to Claude args in `SpawnInTab()`, `resumeTask()`, and `launchNewTask()` -- R5. Mutually exclusive with `--permission-mode plan` in `SpawnInTab()` only -- R6. Esc on the prompt aborts the entire flow -- R7. `SkipPermissions` must only be set from interactive TUI prompts - -## Scope Boundaries - -- No persisted preference — always prompt fresh -- No CLI flag (e.g., `work --skip-permissions`) -- No change to attach flow (`a` key) — that focuses an existing session -- No changes to `Exec()` or `Launch()` — dead code with no callers -- No changes to `root.go` comment/review `SpawnInTab()` callers — they don't prompt interactively, and `SkipPermissions` defaults to `false` - -## Context & Research - -### Relevant Code and Patterns - -- `internal/claude/launch.go:19-28` — `LaunchConfig` struct; `SpawnInTab()` at line 320 handles `PlanMode` at lines 355-357 -- `internal/tui/dashboard/newtask.go:29-35` — step enum (`stepPickRepos`, `stepTaskName`, `stepConfigRepo`, `stepCreating`, `stepDone`); `advanceStep()` at line 350 with three transitions from `stepConfigRepo` to `stepCreating` (lines ~389, ~407, ~443) -- `internal/tui/dashboard/model.go:623-706` — `resumeTask()` inline command builder; `launchNewTask()` at line 1500 reads `m.newTaskView` fields directly -- `internal/tui/dashboard/model.go:42-43` — overlay pattern: `newTaskView *newTaskModel` + `showNewTask bool`; `View()` renders overlay fullscreen at line 1364 -- `internal/tui/dashboard/commands.go:222-226` — `newTaskCreatedMsg` carries worktree results; `launchNewTask()` reads skip-permissions from `m.newTaskView` directly (no need to thread through this message) -- Huh form init pattern: `huh.NewForm(huh.NewGroup(...)).WithTheme(ui.HuhTheme()).WithWidth(m.formWidth()).WithShowHelp(true)` - -## Key Technical Decisions - -- **Overlay for resume (not confirming pattern)**: The `confirming` pattern (y/n in status bar) only supports simple yes/no. The overlay pattern (Huh form rendered fullscreen) matches the requirement for a Select form and is consistent with the new-task wizard aesthetic. (see origin) -- **New step constant (not appended to last config step)**: Adding `stepSkipPermissions` as a discrete step keeps concerns separated and matches the existing step-per-form pattern. (see origin) -- **Skip `Exec()` entirely**: `Exec()` and `Launch()` are dead code with no callers. Adding the flag there would perpetuate dead code. -- **`launchNewTask()` reads from `newTaskModel` directly**: No need to modify `newTaskCreatedMsg` — `launchNewTask()` already accesses `m.newTaskView.taskName` and `m.newTaskView.createdDirs` directly. Same pattern for `skipPermissions`. -- **Resume overlay stores pending task**: The resume skip-permissions overlay needs to remember which task is being resumed. Store a `*service.TaskView` on the overlay model so the resume can proceed after selection. - -## Implementation Units - -- [ ] **Unit 1: Add SkipPermissions to LaunchConfig and SpawnInTab** - - **Goal:** Establish the data model and wire the flag into the `SpawnInTab()` arg builder, mutually exclusive with `PlanMode`. - - **Requirements:** R1, R4, R5 - - **Dependencies:** None - - **Files:** - - Modify: `internal/claude/launch.go` - - **Approach:** - - Add `SkipPermissions bool` field to the `LaunchConfig` struct - - In `SpawnInTab()`, restructure the `PlanMode` block (lines 355-357) into an if/else-if: if `SkipPermissions`, append `--dangerously-skip-permissions`; else if `PlanMode`, append `--permission-mode plan` - - No changes to `Exec()` — it's dead code - - **Patterns to follow:** - - Existing `PlanMode` handling in `SpawnInTab()` at lines 355-357 - - `LaunchConfig` field ordering convention (group related booleans) - - **Test scenarios:** - - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. - - **Verification:** - - `go build ./...` succeeds - - Grep for `SkipPermissions` confirms it appears in `LaunchConfig` and `SpawnInTab()` - -- [ ] **Unit 2: Add stepSkipPermissions to new-task wizard and thread to launchNewTask** - - **Goal:** Add a skip-permissions Select prompt as a new step in the dashboard new-task wizard, and thread the result into the `launchNewTask()` inline command builder. - - **Requirements:** R2, R4, R6, R7 - - **Dependencies:** Unit 1 - - **Files:** - - Modify: `internal/tui/dashboard/newtask.go` - - Modify: `internal/tui/dashboard/model.go` - - **Approach:** - - In `newtask.go`: - - Add `stepSkipPermissions` to the step enum between `stepConfigRepo` and `stepCreating` - - Add `skipPermissions bool` and `skipPermsChoice string` fields to `newTaskModel` - - Create `initSkipPermissions()` method following `initTaskName()` pattern — a `huh.NewForm` with a `huh.NewSelect[string]` with Title `"Skip permission prompts?"` and Description `"⚠ Dangerous: Claude will execute tools without asking for approval. Use with caution."`, offering `huh.NewOption("No (default)", "no")` and `huh.NewOption("Yes — skip all permission checks", "yes")`, bound to `m.skipPermsChoice` (initialized to `"no"`), themed with `ui.HuhTheme()`. Use the same Title and Description for the resume overlay prompt. - - In `advanceStep()`, change the three transitions from `stepConfigRepo → stepCreating` to `stepConfigRepo → stepSkipPermissions` - - Also in `initConfigRepo()` (line ~194), change the fourth transition where all repos auto-configure in resume-from-PR mode (`m.step = stepCreating`) to `stepSkipPermissions` instead - - Add a `stepSkipPermissions` case in `advanceStep()`: read `m.skipPermsChoice`, set `m.skipPermissions = (choice == "yes")`, transition to `stepCreating` - - Add `stepSkipPermissions` rendering to `view()` (if needed — confirm the existing form-rendering path handles it) - - In `model.go`: - - In `launchNewTask()` (line ~1536), after building the args slice, add: if `m.newTaskView.skipPermissions`, append `--dangerously-skip-permissions` to args - - **Patterns to follow:** - - `initTaskName()` in `newtask.go` (lines 139-155) — form creation and binding pattern - - `stepConfigRepo` case in `advanceStep()` — transition logic - - `launchNewTask()` reading `m.newTaskView.createdDirs` and `m.newTaskView.taskName` directly - - **Test scenarios:** - - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. - - **Verification:** - - `go build ./...` succeeds - - Create a new task from the dashboard → after branch config, the skip-permissions Select appears - - Selecting "No (default)" → Claude launches without `--dangerously-skip-permissions` - - Selecting "Yes" → Claude launches with `--dangerously-skip-permissions` - - Pressing Esc on the prompt → returns to dashboard, no task created - -- [ ] **Unit 3: Add skip-permissions overlay for resume flow and thread to resumeTask** - - **Goal:** Add a Huh overlay prompt before resume launches Claude, and thread the result into the `resumeTask()` inline command builder. - - **Requirements:** R3, R4, R6, R7 - - **Dependencies:** Unit 1 - - **Files:** - - Modify: `internal/tui/dashboard/model.go` - - **Approach:** - - Define a small overlay model struct (e.g., `skipPermsPrompt`) in `model.go` with: - - `form *huh.Form` - - `choice string` (bound to the Select) - - `task *service.TaskView` (the pending task to resume) - - `init()` method that creates the Huh form (same Select as the new-task wizard) - - `update(msg)` method that delegates to `m.form.Update(msg)` - - `view()` method that renders the form - - Add `resumePrompt *skipPermsPrompt` and `showResumePrompt bool` fields to the dashboard `Model` struct - - Refactor `resumeTask()` into two parts: (1) `resumeTask()` now only checks for active session (attach flow) then shows the overlay and returns; (2) a new `executeResume(task, skipPermissions)` method contains the existing Prepare + command-building + spawn logic with the flag threaded in. This keeps Prepare() after the user's choice — no wasted work on cancel. - - Note: `handleResumeFromPR()` at model.go:727 also calls `resumeTask()` for the "matching task exists" case — this path will also show the overlay, which is correct (same prompt on every Claude launch). - - Define a new message type (e.g., `resumeSkipPermsMsg`) carrying the choice and task reference - - In `Update()`, handle `resumeSkipPermsMsg`: read the choice, then call `executeResume(task, skipPermissions)` - - In `Update()`, handle form abort (Esc): set `m.showResumePrompt = false`, return to dashboard - - In `View()`, when `m.showResumePrompt` is true, render the overlay fullscreen (same pattern as `showNewTask`) - - In `handleKey()`, when `m.showResumePrompt` is true, delegate to the overlay's update - - **Patterns to follow:** - - `newTaskView` / `showNewTask` overlay lifecycle in `model.go` (lines 42-43, 259-263, 271-273, 1364-1366) - - `newTaskFormCancelMsg` handling (lines 237-239) - - `resumeTask()` existing command-building logic (lines 655-669) — preserve, just add the flag - - **Test scenarios:** - - Test expectation: none — no test files exist in the project. Verification is manual via the TUI. - - **Verification:** - - `go build ./...` succeeds - - Press `r` on an existing task → skip-permissions overlay appears - - Selecting "No (default)" → Claude resumes without `--dangerously-skip-permissions` - - Selecting "Yes" → Claude resumes with `--dangerously-skip-permissions` - - Pressing Esc → returns to dashboard, no Claude session launched - - Resume on a task with an active session → still focuses existing window (attach flow unchanged) - -## System-Wide Impact - -- **Interaction graph:** The skip-permissions flag flows through three independent command builders (`SpawnInTab`, `resumeTask` inline, `launchNewTask` inline). Changes to arg construction in one must be mirrored in the others. -- **API surface parity:** The `root.go` callers of `SpawnInTab()` (comment/review launches) are unaffected — they don't set `SkipPermissions` and get the `false` zero-value default. -- **Unchanged invariants:** The attach flow (`a` key / `handleAttach`) is unchanged. Session tracking, worktree creation, and PR workflows are all unaffected. - -## Risks & Dependencies - -| Risk | Mitigation | -|------|------------| -| Step enum iota shift (adding `stepSkipPermissions` changes numeric values of `stepCreating` and `stepDone`) | All comparisons use named constants, not numeric values — safe. Verify with grep. | -| Resume overlay adds complexity to the dashboard model | Keep the overlay model minimal (single form, single message type). Follow the established `newTaskView` pattern exactly. | -| Three independent command builders must stay in sync | Flag handling is a single `if` + `append` in each — hard to get wrong. Verified by manual testing. | - -## Sources & References - -- **Origin document:** [docs/brainstorms/skip-permissions-requirements.md](docs/brainstorms/skip-permissions-requirements.md) -- Related code: `internal/claude/launch.go` (LaunchConfig, SpawnInTab), `internal/tui/dashboard/newtask.go` (wizard steps), `internal/tui/dashboard/model.go` (resume + launch flows)