From 620fccd1a1759ad403a5fddd95641eb271eeb3ec Mon Sep 17 00:00:00 2001 From: mgreau Date: Fri, 6 Mar 2026 17:03:27 -0500 Subject: [PATCH] Enhance dashboard, agent status, work delete, and add --model flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status dashboard: enrich Feature Work section with branch name, session indicator (● running, ○ past session), and age - Agent status: fix table alignment (ANSI codes no longer break columns), sort by last active (most recent first), show worktree name instead of path - Work delete: fix name-based matching (was broken by premature filepath.Abs), show summary before confirming, clean up Claude session files on delete - Add --model/-m flag to work new, work resume, review, and review resume to specify the Claude model when opening iTerm tabs - Add session.ProjectDir() helper for session directory cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/agent.go | 79 ++++++++++++++++--------- cmd/resume.go | 30 +++++++--- cmd/review.go | 18 +++++- cmd/status.go | 88 +++++++++++++++++++++------- cmd/work.go | 117 ++++++++++++++++++++++++++++--------- internal/iterm/tab.go | 25 ++++++-- internal/session/detect.go | 10 ++++ 7 files changed, 276 insertions(+), 91 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 738470e..bcd20cc 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "sort" "strings" "text/tabwriter" "time" @@ -41,14 +42,15 @@ func init() { // agentStatusEntry holds one row of the agent status output. type agentStatusEntry struct { - Worktree string `json:"worktree"` - SessionID string `json:"session_id"` - Status string `json:"status"` - Size string `json:"size"` - Model string `json:"model"` - InputTokens string `json:"input_tokens"` - OutputTokens string `json:"output_tokens"` - LastActive string `json:"last_active"` + Worktree string `json:"worktree"` + SessionID string `json:"session_id"` + Status string `json:"status"` + Size string `json:"size"` + Model string `json:"model"` + InputTokens string `json:"input_tokens"` + OutputTokens string `json:"output_tokens"` + LastActive string `json:"last_active"` + lastActiveEpoch int64 // unexported, for sorting only } func runAgentStatus(cmd *cobra.Command, args []string) error { @@ -97,18 +99,24 @@ func runAgentStatus(cmd *cobra.Command, args []string) error { lastActive := time.Unix(s.Modified, 0) entry := agentStatusEntry{ - Worktree: ui.ShortenHome(wt.Path, home), - SessionID: s.ID, - Status: status, - Size: s.SizeStr, - Model: session.ShortenModel(model), - InputTokens: session.FormatTokenCount(tokens.InputTokens), - OutputTokens: session.FormatTokenCount(tokens.OutputTokens), - LastActive: session.FormatAge(lastActive), + Worktree: ui.ShortenHome(wt.Path, home), + SessionID: s.ID, + Status: status, + Size: s.SizeStr, + Model: session.ShortenModel(model), + InputTokens: session.FormatTokenCount(tokens.InputTokens), + OutputTokens: session.FormatTokenCount(tokens.OutputTokens), + LastActive: session.FormatAge(lastActive), + lastActiveEpoch: s.Modified, } entries = append(entries, entry) } + // Sort by last active (most recent first) + sort.Slice(entries, func(i, j int) bool { + return entries[i].lastActiveEpoch > entries[j].lastActiveEpoch + }) + if jsonFlag { printJSON(entries) return nil @@ -127,28 +135,33 @@ func runAgentStatus(cmd *cobra.Command, args []string) error { ui.SectionHeader("Agent Sessions") fmt.Println() + // Compute max worktree name width for alignment + maxWT := len("WORKTREE") + for _, e := range entries { + name := worktreeDisplayName(e.Worktree) + if len(name) > maxWT { + maxWT = len(name) + } + } + + // Use tabwriter only for plain-text columns, then append colored status after w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "WORKTREE\tSTATUS\tSIZE\tMODEL\tTOKENS (IN/OUT)\tLAST ACTIVE") - fmt.Fprintln(w, "--------\t------\t----\t-----\t---------------\t-----------") + fmt.Fprintf(w, "%-7s %-*s %-7s %-6s %-12s %s\n", "STATUS", maxWT, "WORKTREE", "SIZE", "MODEL", "TOKENS(I/O)", "LAST ACTIVE") + fmt.Fprintf(w, "%-7s %-*s %-7s %-6s %-12s %s\n", "───────", maxWT, strings.Repeat("─", maxWT), "───────", "──────", "────────────", "───────────") for _, e := range entries { - statusStr := e.Status + statusStr := fmt.Sprintf("%-7s", e.Status) if e.Status == "running" { - statusStr = ui.GreenText("running") + statusStr = ui.GreenText(statusStr) } else { - statusStr = ui.DimText("stopped") + statusStr = ui.DimText(statusStr) } tokenStr := fmt.Sprintf("%s/%s", e.InputTokens, e.OutputTokens) + name := worktreeDisplayName(e.Worktree) - // Shorten worktree path for display - wtDisplay := e.Worktree - if parts := strings.Split(wtDisplay, "/"); len(parts) > 2 { - wtDisplay = "~/" + strings.Join(parts[len(parts)-2:], "/") - } - - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", - wtDisplay, statusStr, e.Size, e.Model, tokenStr, e.LastActive) + fmt.Fprintf(w, "%s %-*s %-7s %-6s %-12s %s\n", + statusStr, maxWT, name, e.Size, e.Model, tokenStr, ui.DimText(e.LastActive)) } w.Flush() @@ -164,3 +177,11 @@ func runAgentStatus(cmd *cobra.Command, args []string) error { return nil } + +// worktreeDisplayName extracts the last path component (worktree dir name) for display. +func worktreeDisplayName(path string) string { + if parts := strings.Split(path, "/"); len(parts) > 0 { + return parts[len(parts)-1] + } + return path +} diff --git a/cmd/resume.go b/cmd/resume.go index 6c4fbcf..06b7106 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -19,6 +19,7 @@ var ( resumeSession int resumeList bool resumeNoITerm bool + resumeModel string ) // resumeWorktree handles the core resume logic for a matched worktree. @@ -90,7 +91,11 @@ func resumeWorktree(wt worktree.Worktree, cmdName string) error { if resumeNoITerm { fmt.Println() fmt.Println(ui.BoldText("Resume command:")) - fmt.Printf(" cd %s && %s --resume %s\n", wt.Path, cfg.ClaudeBin, s.ID) + modelFlag := "" + if resumeModel != "" { + modelFlag = fmt.Sprintf(" --model %s", resumeModel) + } + fmt.Printf(" cd %s && %s%s --resume %s\n", wt.Path, cfg.ClaudeBin, modelFlag, s.ID) fmt.Println() fmt.Println(ui.DimText(fmt.Sprintf("Worktree: %s", shortPath))) fmt.Println(ui.DimText(fmt.Sprintf("Session: %s (%s)", s.ModHuman, s.SizeStr))) @@ -104,9 +109,12 @@ func resumeWorktree(wt worktree.Worktree, cmdName string) error { fmt.Printf(" Path: %s\n", ui.DimText(shortPath)) fmt.Printf(" Session: %s\n", ui.DimText(s.ID)) fmt.Printf(" Modified: %s\n", ui.DimText(fmt.Sprintf("%s (%s)", s.ModHuman, s.SizeStr))) + if resumeModel != "" { + fmt.Printf(" Model: %s\n", ui.CyanText(resumeModel)) + } fmt.Println() - if err := iterm.OpenTabWithResume(wt.Path, s.ID, cfg.ClaudeBin); err != nil { + if err := iterm.OpenTabWithResume(wt.Path, s.ID, cfg.ClaudeBin, resumeModel); err != nil { return fmt.Errorf("opening iTerm tab: %w", err) } @@ -130,10 +138,14 @@ func openNewSession(wt worktree.Worktree) error { if resumeNoITerm { fmt.Println() fmt.Println(ui.BoldText("Start command:")) + modelFlag := "" + if resumeModel != "" { + modelFlag = fmt.Sprintf(" --model %s", resumeModel) + } if initialPrompt != "" { - fmt.Printf(" cd %s && %s %q\n", wt.Path, cfg.ClaudeBin, initialPrompt) + fmt.Printf(" cd %s && %s%s %q\n", wt.Path, cfg.ClaudeBin, modelFlag, initialPrompt) } else { - fmt.Printf(" cd %s && %s\n", wt.Path, cfg.ClaudeBin) + fmt.Printf(" cd %s && %s%s\n", wt.Path, cfg.ClaudeBin, modelFlag) } fmt.Println() fmt.Println(ui.DimText(fmt.Sprintf("Worktree: %s", shortPath))) @@ -144,13 +156,16 @@ func openNewSession(wt worktree.Worktree) error { fmt.Println(ui.BoldText(fmt.Sprintf("%s in new iTerm2 tab", action))) fmt.Printf(" Worktree: %s\n", ui.CyanText(wt.Name)) fmt.Printf(" Path: %s\n", ui.DimText(shortPath)) + if resumeModel != "" { + fmt.Printf(" Model: %s\n", ui.CyanText(resumeModel)) + } fmt.Println() var err error if initialPrompt != "" { - err = iterm.OpenTabWithClaude(wt.Path, initialPrompt, cfg.ClaudeBin) + err = iterm.OpenTabWithClaude(wt.Path, initialPrompt, cfg.ClaudeBin, resumeModel) } else { - err = iterm.OpenTab(wt.Path, cfg.ClaudeBin) + err = iterm.OpenTabWithClaudeModel(wt.Path, cfg.ClaudeBin, resumeModel) } if err != nil { return fmt.Errorf("opening iTerm tab: %w", err) @@ -222,11 +237,12 @@ func findWorktreeByName(term string) (*worktree.Worktree, error) { return &matches[0], nil } -// addResumeFlags adds the shared --session, --list, --no-iterm flags to a cobra command. +// addResumeFlags adds the shared --session, --list, --no-iterm, --model flags to a cobra command. func addResumeFlags(cmd *cobra.Command) { cmd.Flags().IntVarP(&resumeSession, "session", "s", 0, "Resume Nth session instead of most recent (1-based)") cmd.Flags().BoolVarP(&resumeList, "list", "l", false, "List available sessions without resuming") cmd.Flags().BoolVar(&resumeNoITerm, "no-iterm", false, "Print the resume command instead of opening iTerm2") + cmd.Flags().StringVarP(&resumeModel, "model", "m", "", "Claude model to use (e.g., sonnet, opus, haiku)") } // runReviewResume handles `zen review resume `. diff --git a/cmd/review.go b/cmd/review.go index 781a7f4..f48c7c4 100644 --- a/cmd/review.go +++ b/cmd/review.go @@ -48,12 +48,14 @@ var reviewDeleteCmd = &cobra.Command{ var ( reviewRepo string reviewNoITerm bool + reviewModel string reviewDeleteForce bool ) func init() { reviewCmd.Flags().StringVar(&reviewRepo, "repo", "", "Repository short name from config (auto-detected if omitted)") reviewCmd.Flags().BoolVar(&reviewNoITerm, "no-iterm", false, "Create worktree only, don't open iTerm2 tab") + reviewCmd.Flags().StringVarP(&reviewModel, "model", "m", "", "Claude model to use (e.g., sonnet, opus, haiku)") addResumeFlags(reviewResumeCmd) reviewDeleteCmd.Flags().BoolVarP(&reviewDeleteForce, "force", "f", false, "Skip confirmation") reviewCmd.AddCommand(reviewResumeCmd) @@ -104,6 +106,10 @@ func runReview(cmd *cobra.Command, args []string) error { // If worktree already exists, resume it if _, err := os.Stat(worktreePath); err == nil { ui.LogInfo(fmt.Sprintf("Worktree already exists, resuming PR #%d...", prNumber)) + // Pass model through to resume path + if reviewModel != "" { + resumeModel = reviewModel + } return openReviewTab(worktreePath, worktreeName) } @@ -174,15 +180,23 @@ func runReview(cmd *cobra.Command, args []string) error { fmt.Printf(" PR: #%d — %s\n", prNumber, details.Title) fmt.Printf(" Author: %s\n", details.Author) + if reviewModel != "" { + fmt.Printf(" Model: %s\n", ui.CyanText(reviewModel)) + } + if reviewNoITerm { fmt.Println() fmt.Println(ui.BoldText("Open manually:")) - fmt.Printf(" cd %s && %s \"/review-pr\"\n", worktreePath, cfg.ClaudeBin) + modelFlag := "" + if reviewModel != "" { + modelFlag = fmt.Sprintf(" --model %s", reviewModel) + } + fmt.Printf(" cd %s && %s%s \"/review-pr\"\n", worktreePath, cfg.ClaudeBin, modelFlag) return nil } // Open iTerm tab - if err := iterm.OpenTabWithClaude(worktreePath, "/review-pr", cfg.ClaudeBin); err != nil { + if err := iterm.OpenTabWithClaude(worktreePath, "/review-pr", cfg.ClaudeBin, reviewModel); err != nil { return fmt.Errorf("opening iTerm tab: %w", err) } diff --git a/cmd/status.go b/cmd/status.go index eac3604..81d7915 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -13,6 +13,7 @@ import ( "github.com/mgreau/zen/internal/config" "github.com/mgreau/zen/internal/github" "github.com/mgreau/zen/internal/prcache" + "github.com/mgreau/zen/internal/session" "github.com/mgreau/zen/internal/ui" "github.com/mgreau/zen/internal/worktree" "github.com/spf13/cobra" @@ -33,7 +34,7 @@ func init() { type StatusData struct { Worktrees *worktree.Stats `json:"worktrees"` PRReviews []StatusPRReview `json:"pr_reviews"` - Features []worktree.Worktree `json:"features"` + Features []StatusFeature `json:"features"` DaemonStatus string `json:"daemon_status"` DaemonPID string `json:"daemon_pid,omitempty"` } @@ -47,6 +48,15 @@ type StatusPRReview struct { CleanupIn int `json:"cleanup_in_days,omitempty"` } +// StatusFeature enriches a feature worktree with session and age info. +type StatusFeature struct { + worktree.Worktree + AgeDays int `json:"age_days"` + AgeStr string `json:"age_str"` + HasSession bool `json:"has_session"` + Running bool `json:"running"` +} + func runStatus(cmd *cobra.Command, args []string) error { // Worktree stats wtStats, err := worktree.GetStats(cfg) @@ -72,6 +82,9 @@ func runStatus(cmd *cobra.Command, args []string) error { prCache := prcache.Load() prReviews := enrichPRReviews(prWTs, prCache) + // Enrich features with session and age info + enrichedFeatures := enrichFeatures(features) + // Daemon status daemonStatus, daemonPID := getDaemonStatus() @@ -79,7 +92,7 @@ func runStatus(cmd *cobra.Command, args []string) error { printJSON(StatusData{ Worktrees: wtStats, PRReviews: prReviews, - Features: features, + Features: enrichedFeatures, DaemonStatus: daemonStatus, DaemonPID: daemonPID, }) @@ -123,37 +136,38 @@ func runStatus(cmd *cobra.Command, args []string) error { // Features — sorted by age (newest first) ui.SectionHeader("Feature Work") - if len(features) == 0 { + if len(enrichedFeatures) == 0 { fmt.Println(" No feature worktrees") } else { - sort.Slice(features, func(i, j int) bool { - di, _ := worktree.AgeDays(features[i].Path) - dj, _ := worktree.AgeDays(features[j].Path) - return di < dj + sort.Slice(enrichedFeatures, func(i, j int) bool { + return enrichedFeatures[i].AgeDays < enrichedFeatures[j].AgeDays }) - fmt.Printf(" %-42s %-5s %s\n", "Name", "Age", "Path") - fmt.Printf(" %-42s %-5s %s\n", "──────────────────────────────────────────", "─────", "──────────────────────────────") + fmt.Printf(" %-3s %-34s %-22s %-5s %s\n", "", "Name", "Branch", "Age", "Path") + fmt.Printf(" %-3s %-34s %-22s %-5s %s\n", "───", "──────────────────────────────────", "──────────────────────", "─────", "──────────────────────────────") - for i, f := range features { + for i, f := range enrichedFeatures { if i >= 15 { - fmt.Printf(" ... and %d more\n", len(features)-15) + fmt.Printf(" ... and %d more\n", len(enrichedFeatures)-15) break } - age := "" - if days, err := worktree.AgeDays(f.Path); err == nil && days >= 0 { - if days == 0 { - if hours, err := worktree.AgeHours(f.Path); err == nil { - age = fmt.Sprintf("%dh", hours) - } - } else { - age = fmt.Sprintf("%dd", days) - } + sessionIcon := " " + if f.Running { + sessionIcon = ui.GreenText(" ● ") + } else if f.HasSession { + sessionIcon = ui.DimText(" ○ ") } - fmt.Printf(" %-42s %-5s %s\n", f.Name, ui.DimText(age), ui.DimText(ui.ShortenHome(f.Path, home))) + branch := ui.Truncate(f.Branch, 22) + name := ui.Truncate(f.Name, 34) + fmt.Printf(" %s %-34s %s %-5s %s\n", + sessionIcon, + name, + ui.CyanText(fmt.Sprintf("%-22s", branch)), + ui.DimText(f.AgeStr), + ui.DimText(ui.ShortenHome(f.Path, home))) } } - ui.Hint("'zen work resume ' to continue | 'zen work new ' to start") + ui.Hint("'zen work resume ' to continue | 'zen work new ' to start | ● = active session") fmt.Println() // Watch daemon @@ -172,6 +186,36 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } +// enrichFeatures builds StatusFeature entries with age and session info. +func enrichFeatures(wts []worktree.Worktree) []StatusFeature { + features := make([]StatusFeature, 0, len(wts)) + for _, wt := range wts { + f := StatusFeature{Worktree: wt} + + // Age + if days, err := worktree.AgeDays(wt.Path); err == nil && days >= 0 { + f.AgeDays = days + if days == 0 { + if hours, err := worktree.AgeHours(wt.Path); err == nil { + f.AgeStr = fmt.Sprintf("%dh", hours) + } + } else { + f.AgeStr = fmt.Sprintf("%dd", days) + } + } + + // Session status + sessions, _ := session.FindSessions(wt.Path) + if len(sessions) > 0 { + f.HasSession = true + f.Running = session.IsProcessRunning(sessions[0].ID) + } + + features = append(features, f) + } + return features +} + // enrichPRReviews builds StatusPRReview entries with remote state and cleanup ETA. // Falls back gracefully if GitHub is unreachable. func enrichPRReviews(wts []worktree.Worktree, prCache map[string]prcache.PRMeta) []StatusPRReview { diff --git a/cmd/work.go b/cmd/work.go index 7889a15..ab1ab7a 100644 --- a/cmd/work.go +++ b/cmd/work.go @@ -31,10 +31,14 @@ Optionally provide a context string to use as the initial Claude prompt.`, } var workDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a feature worktree by path or name", - Args: cobra.ExactArgs(1), - RunE: runWorkDelete, + Use: "delete ", + Short: "Delete a feature worktree by name or path", + Long: `Delete a feature worktree and its Claude session files. + +Accepts a worktree name (e.g., mono-factory-v2-agentic) or full path. +Shows a summary of what will be removed before confirming.`, + Args: cobra.ExactArgs(1), + RunE: runWorkDelete, } var workResumeCmd = &cobra.Command{ @@ -46,11 +50,13 @@ var workResumeCmd = &cobra.Command{ var ( workNewNoITerm bool + workNewModel string workDeleteForce bool ) func init() { workNewCmd.Flags().BoolVar(&workNewNoITerm, "no-iterm", false, "Create worktree only, don't open iTerm2 tab") + workNewCmd.Flags().StringVarP(&workNewModel, "model", "m", "", "Claude model to use (e.g., sonnet, opus, haiku)") workDeleteCmd.Flags().BoolVarP(&workDeleteForce, "force", "f", false, "Skip confirmation") addResumeFlags(workResumeCmd) workCmd.AddCommand(workNewCmd) @@ -178,23 +184,34 @@ func runWorkNew(cmd *cobra.Command, args []string) error { ui.LogSuccess(fmt.Sprintf("Created worktree: %s", shortPath)) fmt.Printf(" Branch: %s\n", ui.CyanText(gitBranch)) + if workNewModel != "" { + fmt.Printf(" Model: %s\n", ui.CyanText(workNewModel)) + } + if workNewNoITerm { fmt.Println() fmt.Println(ui.BoldText("Open manually:")) + modelFlag := "" + if workNewModel != "" { + modelFlag = fmt.Sprintf(" --model %s", workNewModel) + } if context != "" { - fmt.Printf(" cd %s && %s %q\n", worktreePath, cfg.ClaudeBin, context) + fmt.Printf(" cd %s && %s%s %q\n", worktreePath, cfg.ClaudeBin, modelFlag, context) } else { - fmt.Printf(" cd %s && %s\n", worktreePath, cfg.ClaudeBin) + fmt.Printf(" cd %s && %s%s\n", worktreePath, cfg.ClaudeBin, modelFlag) } return nil } // Open iTerm tab - if context == "" { - context = "/review-pr" - } - if err := iterm.OpenTabWithClaude(worktreePath, context, cfg.ClaudeBin); err != nil { - return fmt.Errorf("opening iTerm tab: %w", err) + if context != "" { + if err := iterm.OpenTabWithClaude(worktreePath, context, cfg.ClaudeBin, workNewModel); err != nil { + return fmt.Errorf("opening iTerm tab: %w", err) + } + } else { + if err := iterm.OpenTabWithClaudeModel(worktreePath, cfg.ClaudeBin, workNewModel); err != nil { + return fmt.Errorf("opening iTerm tab: %w", err) + } } ui.LogSuccess("iTerm2 tab opened") @@ -205,15 +222,7 @@ func runWorkNew(cmd *cobra.Command, args []string) error { func runWorkDelete(cmd *cobra.Command, args []string) error { target := args[0] - // Resolve to absolute path if relative - if !filepath.IsAbs(target) { - abs, err := filepath.Abs(target) - if err == nil { - target = abs - } - } - - // Find matching worktree + // Find matching worktree by name first, then by path wts, err := wt.ListAll(cfg) if err != nil { return fmt.Errorf("listing worktrees: %w", err) @@ -221,12 +230,28 @@ func runWorkDelete(cmd *cobra.Command, args []string) error { var match *wt.Worktree for _, w := range wts { - if w.Path == target || w.Name == target { + if w.Name == target { w := w match = &w break } } + if match == nil { + // Try absolute path match + absTarget := target + if !filepath.IsAbs(absTarget) { + if abs, err := filepath.Abs(absTarget); err == nil { + absTarget = abs + } + } + for _, w := range wts { + if w.Path == absTarget { + w := w + match = &w + break + } + } + } if match == nil { return fmt.Errorf("no worktree found matching %q", target) @@ -235,19 +260,44 @@ func runWorkDelete(cmd *cobra.Command, args []string) error { home := homeDir() shortPath := ui.ShortenHome(match.Path, home) - if !workDeleteForce { - fmt.Printf("Delete worktree %s?\n", ui.CyanText(match.Name)) - fmt.Printf(" Path: %s\n", shortPath) - fmt.Print(" Confirm [y/N]: ") + // Gather info for summary + sessions, _ := session.FindSessions(match.Path) + age := "" + if days, err := wt.AgeDays(match.Path); err == nil { + if days == 0 { + if hours, herr := wt.AgeHours(match.Path); herr == nil { + age = fmt.Sprintf("%dh", hours) + } + } else { + age = fmt.Sprintf("%dd", days) + } + } + + // Show summary + fmt.Println() + fmt.Printf(" Worktree: %s\n", ui.CyanText(match.Name)) + fmt.Printf(" Branch: %s\n", match.Branch) + fmt.Printf(" Path: %s\n", shortPath) + if age != "" { + fmt.Printf(" Age: %s\n", age) + } + if len(sessions) > 0 { + fmt.Printf(" Sessions: %d (%s)\n", len(sessions), sessions[0].SizeStr) + } + fmt.Println() + if !workDeleteForce { + fmt.Print(" Delete? [y/N]: ") var resp string fmt.Scanln(&resp) if resp != "y" && resp != "Y" { - fmt.Println("Cancelled.") + fmt.Println(" Cancelled.") return nil } + fmt.Println() } + // Remove git worktree basePath := cfg.RepoBasePath(match.Repo) originPath := filepath.Join(basePath, match.Repo) @@ -256,7 +306,20 @@ func runWorkDelete(cmd *cobra.Command, args []string) error { if out, err := removeCmd.CombinedOutput(); err != nil { return fmt.Errorf("git worktree remove: %w: %s", err, string(out)) } + ui.LogSuccess("Removed worktree") + + // Clean up Claude session files + if len(sessions) > 0 { + sessionDir := session.ProjectDir(match.Path) + if sessionDir != "" { + if err := os.RemoveAll(sessionDir); err != nil { + fmt.Printf(" %s clean session files: %v\n", ui.YellowText("Warning:"), err) + } else { + ui.LogSuccess(fmt.Sprintf("Cleaned %d session file(s)", len(sessions))) + } + } + } - ui.LogSuccess(fmt.Sprintf("Deleted worktree: %s", shortPath)) + fmt.Println() return nil } diff --git a/internal/iterm/tab.go b/internal/iterm/tab.go index 826a7ad..f24534c 100644 --- a/internal/iterm/tab.go +++ b/internal/iterm/tab.go @@ -61,13 +61,30 @@ end tell` } // OpenTabWithResume opens a new iTerm2 tab to resume a Claude session. -func OpenTabWithResume(workDir, sessionID, claudeBin string) error { - cmd := fmt.Sprintf("%s --resume %s", claudeBin, sessionID) +func OpenTabWithResume(workDir, sessionID, claudeBin, model string) error { + cmd := claudeBin + if model != "" { + cmd += fmt.Sprintf(" --model %s", model) + } + cmd += fmt.Sprintf(" --resume %s", sessionID) return OpenTab(workDir, cmd) } // OpenTabWithClaude opens a new iTerm2 tab with Claude and an initial prompt. -func OpenTabWithClaude(workDir, initialPrompt, claudeBin string) error { - cmd := fmt.Sprintf("%s %q", claudeBin, initialPrompt) +func OpenTabWithClaude(workDir, initialPrompt, claudeBin, model string) error { + cmd := claudeBin + if model != "" { + cmd += fmt.Sprintf(" --model %s", model) + } + cmd += fmt.Sprintf(" %q", initialPrompt) + return OpenTab(workDir, cmd) +} + +// OpenTabWithClaudeModel opens a new iTerm2 tab with Claude using a specific model (no prompt). +func OpenTabWithClaudeModel(workDir, claudeBin, model string) error { + cmd := claudeBin + if model != "" { + cmd += fmt.Sprintf(" --model %s", model) + } return OpenTab(workDir, cmd) } diff --git a/internal/session/detect.go b/internal/session/detect.go index c8a1c49..40a88db 100644 --- a/internal/session/detect.go +++ b/internal/session/detect.go @@ -68,6 +68,16 @@ func HasActiveSession(worktreePath string) bool { return len(sessions) > 0 } +// ProjectDir returns the Claude projects directory for a worktree path. +// Returns empty string if the directory doesn't exist. +func ProjectDir(worktreePath string) string { + dir := filepath.Join(os.Getenv("HOME"), ".claude", "projects", pathToClaudeProject(worktreePath)) + if _, err := os.Stat(dir); err != nil { + return "" + } + return dir +} + // pathToClaudeProject converts a worktree path to the Claude projects directory name. // /Users/maxime.greau/git/cgr/repo-mono/mono-pr-123 // -> -Users-maxime-greau-git-cgr-repo-mono-mono-pr-123