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/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")