diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b27cc..6114095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-07-13 + +### Added + +- **Multiple tasks per workspace.** A workspace can now hold several independent + Claude sessions ("tasks") that share the same project directory — work on a + refactor, tests, and docs in parallel without cloning the repo. New commands: + - `crabby task create ` — start a new task and open it. + - `crabby task list [workspace]` — list a workspace's tasks and their state. + - `crabby task delete [workspace]` — remove a task (the workspace stays). + - `crabby task restart [workspace]` — stop and relaunch a task. +- `crabby attach [workspace] [task]` now asks which task to open when a + workspace has more than one; pass the task name to skip the prompt. +- The dashboard groups tasks beneath their workspace and adds a **t** key to + start a new task in the selected workspace. + +### Changed + +- **Dashboard redesign.** The banner stays centred, but the workspace list and + footer are now left-aligned like a normal CLI, with each workspace's data laid + out in a column to the right of its name. Action keys in the footer are + highlighted so they read distinctly from the informational usage summary. +- Session names for named tasks are generated automatically as + `crabby__`; the default task keeps the `crabby_` + name so single-task workspaces are unchanged. + +### Notes + +- Existing single-task workspaces migrate automatically: each becomes a + workspace with one default "main" task on its original session. No action is + required. +- With several tasks sharing one directory, transcript-derived detail (model, + tokens, fine activity) is shown at the workspace level; per-task rows show the + reliable tmux state, since Claude's transcripts can't be attributed to a + specific tmux session. + ## [0.6.0] - 2026-07-13 ### Added diff --git a/README.md b/README.md index dd0e472..feb5a94 100644 --- a/README.md +++ b/README.md @@ -93,52 +93,85 @@ crabby ``` ``` - (\/) (\/) - \(o..o)/ - /`----'\ + (\/) (\/) + \(o..o)/ + /`----'\ - C R A B B Y - Many Claudes. One shell. -prepare projects · orchestrate claude · one terminal -────────────────────────────────────────────────── + C R A B B Y + Many Claudes. One shell. + prepare projects · orchestrate claude · one terminal -▌ ● payments - Thinking… · 20s ago - feature/refunds · up 2h14m · Opus 4.8 · 132k tok +──────────────────────────────────────────────────── - ● frontend - Waiting for input · 8m ago - main · Sonnet 5 · 48k tok + ● test Waiting for input · 1m ago + main · up 8m · Sonnet 5 · 64k tok - ○ docs - Stopped + payments feature/refunds · Opus 4.8 · 132k tok +▌ ● refactor Working · up 2h + ● tests Idle · up 34m + ○ docs Stopped -────────────────────────────────────────────────── -3 workspaces · 2 waiting · 1 stopped · 1 working · 180k tokens today -enter open n new x stop d remove + ○ SSH-AI Stopped + +──────────────────────────────────────────────────── +3 workspaces · 3 active · 2 stopped · 1 working · 196k tokens today + +enter open n new workspace t new task x stop d remove r refresh q quit · F12 returns from a session ``` -The dashboard **refreshes automatically** (about once a second), sorts projects -by importance (**Running → Waiting → Stopped**, then alphabetically), and shows -what each workspace is doing right now. State is colour-coded: green (running), -yellow (waiting), grey (stopped). See [Workspace insights](#workspace-insights) -for what each line means and where the data comes from. +The banner stays centred; the list and footer are left-aligned like a normal +CLI, with each row's live data in a column to the right of its name. A workspace +with one task shows as a single line; a workspace with several (like `payments` +above) becomes a header with its tasks beneath it. The dashboard **refreshes +automatically** (about once a second) and sorts by importance (**Running → +Waiting → Stopped**, then alphabetically). See +[Workspaces and tasks](#workspaces-and-tasks) for the model and +[Workspace insights](#workspace-insights) for where the data comes from. -- **Enter** opens the selected project in Claude. If it wasn't running, Crabby +- **Enter** opens the selected task in Claude. If it wasn't running, Crabby starts it for you. - **F12** (inside a session) brings you straight back to this list — no tmux shortcuts to learn. The session keeps running in the background. -- Pick another project and you're in a different Claude in one keypress. -- **n** adds the current directory as a project (see [Packs](#packs)). -- **x** stops the highlighted session (asks first) — no need to exit Claude. -- **d** removes the highlighted project from Crabby (asks first). Your files are - kept; only Crabby forgets it. Also available as `crabby rm [project]`. +- **n** adds the current directory as a new workspace (see [Packs](#packs)). +- **t** starts a new task in the selected workspace. +- **x** stops the highlighted task (asks first) — no need to exit Claude. +- **d** removes the highlighted task, or the whole workspace for a single-task + row (asks first). Your files are kept; only Crabby forgets it. - **q** quits. The return key is shown on a small Crabby status bar while you work, so you never have to remember it. +## Workspaces and tasks + +Crabby has three simple concepts: + +- **Workspace** — a project directory with a `CLAUDE.md`. This is the source of + truth; it's what `crabby init` and `crabby import` register. +- **Task** — one Claude Code session working inside a workspace. A workspace can + have several, each an independent session **sharing the same directory**. +- **Claude session** — the actual running Claude, one per task, kept alive in + its own tmux session (named automatically, e.g. `crabby_payments_tests`). + +One workspace, multiple tasks. This lets you run parallel efforts in the same +repository without cloning it: + +```bash +cd ~/repos/payments +crabby task create refactor # start a "refactor" task and jump in +crabby task create tests # a second, independent Claude in the same repo +crabby task list # see them both +crabby attach payments # asks which task to open +crabby attach payments tests # or open one directly +crabby task delete refactor # done with it — the workspace stays +``` + +Every workspace starts with one default task (`main`), so if you never touch +`crabby task`, nothing changes: a workspace behaves exactly like a single +session did before. Tasks are independent — Crabby does not coordinate them, +share memory between them, or manage git branches; each is just its own Claude. + ## Workspace insights Crabby reads Claude Code's own session transcripts to show, at a glance, what is @@ -242,12 +275,16 @@ branch is shown for context; when it isn't, it's still importable. | Command | What it does | | --- | --- | | `crabby` | Open the home screen (this is all you normally need). | -| `crabby init [path]` | Register a project (the current directory, or `path`). Creates `.claude/crabby.yaml`, and a starter `CLAUDE.md` if none exists. | +| `crabby init [path]` | Register a workspace (the current directory, or `path`). Creates `.claude/crabby.yaml`, and a starter `CLAUDE.md` if none exists. | | `crabby import [path]` | Discover folders that already have a `CLAUDE.md` and import the ones you pick (the current directory, or `path`). | +| `crabby task create ` | Create a task (a parallel Claude session) in the current workspace and open it. | +| `crabby task list [workspace]` | List a workspace's tasks and their state. | +| `crabby task delete [workspace]` | Delete a task (the workspace stays). | +| `crabby task restart [workspace]` | Stop a task's session and start it fresh. | | `crabby ps` | Alias for `crabby`. | -| `crabby attach [project]` | Open a specific project's session directly. | -| `crabby start [project]` | Same as attach — launches the session if needed. | -| `crabby rm [project]` | Remove a project from Crabby (files are kept). | +| `crabby attach [workspace] [task]` | Open a workspace's session; asks which task when several exist, or pass the task name. | +| `crabby start [workspace] [task]` | Same as attach — launches the session if needed. | +| `crabby rm [workspace]` | Remove a workspace from Crabby, stopping its tasks (files are kept). | | `crabby doctor` | Check that WSL, Ubuntu, tmux, Claude, and crabby are present. | | `crabby version` | Print the installed version. | @@ -327,8 +364,9 @@ and shows it in the selector, so you always know where they go. | 🟡 `Waiting` | Claude is alive in the background; press Enter to jump back in. | | ⚪ `Stopped` | Nothing running; press Enter to start fresh. | -A `Waiting` session that is actively producing output is tagged `· working`, so -you can see at a glance which of your Claude workers are busy. +A live session that is actively producing output shows what it's doing (e.g. +`Working`, `Thinking…`, `Editing files`), so you can see at a glance which of +your Claude workers are busy. See [Workspace insights](#workspace-insights). ## Notifications @@ -397,7 +435,7 @@ publishes the release automatically. ## Out of scope (for now) -Git integration, worktrees, GitHub/PRs, VSCode, multiple Claude sessions per -project, plugins, hooks, templates, profiles, snapshots, memory, AI workflows, -context generation, skills, MCP management. See the technical document for the -roadmap. +Git integration, worktrees, GitHub/PRs, VSCode, coordination or shared memory +between tasks, plugins, hooks, templates, profiles, snapshots, memory, AI +workflows, context generation, skills, MCP management. See the technical +document for the roadmap. diff --git a/VERSION b/VERSION index a918a2a..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.7.0 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 056469d..4f27b55 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "github.com/spf13/cobra" @@ -17,6 +18,7 @@ import ( "github.com/marioolf/crabby/internal/insights" "github.com/marioolf/crabby/internal/pack" "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/session" "github.com/marioolf/crabby/internal/tmux" "github.com/marioolf/crabby/internal/tui" "github.com/marioolf/crabby/internal/version" @@ -55,6 +57,7 @@ func newRootCmd() *cobra.Command { root.AddCommand( newInitCmd(), newImportCmd(), + newTaskCmd(), newPsCmd(), newAttachCmd(), newStartCmd(), @@ -90,17 +93,37 @@ func home() error { case tui.ActionQuit: return nil case tui.ActionOpen: - if err := openSession(cfg, t, *res.Project); err != nil { + if err := openTask(cfg, t, *res.Project, *res.Task); err != nil { pause(err) } case tui.ActionNewProject: if err := newProject(); err != nil { pause(err) } + case tui.ActionNewTask: + if err := newTaskInteractive(cfg, t, *res.Project); err != nil { + pause(err) + } } } } +// newTaskInteractive is the dashboard's "t" key: ask for a task name, create it, +// and open it — so a parallel session starts without leaving Crabby. +func newTaskInteractive(cfg config.Config, t tmux.Client, p project.Project) error { + fmt.Printf("New task in %q.\nTask name: ", p.Name) + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + name := strings.TrimSpace(line) + if name == "" { + return nil + } + tk, err := createTask(p, name) + if err != nil { + return err + } + return openTask(cfg, t, p, tk) +} + // pause shows a problem and waits, so it isn't lost when the home screen // redraws over it. func pause(err error) { @@ -173,24 +196,89 @@ func printInitSummary(res initcmd.Result) { } } -// openSession launches Claude for a project if needed and attaches to it, -// blocking until the user leaves. Selecting a project is all it takes — a -// stopped project is started automatically. -func openSession(cfg config.Config, t tmux.Client, p project.Project) error { - if !t.HasSession(p.Session) { +// openTask launches Claude for a task if its session isn't running yet and +// attaches to it, blocking until the user leaves. All tasks in a workspace +// share the same project directory. +func openTask(cfg config.Config, t tmux.Client, p project.Project, task project.Task) error { + if !t.HasSession(task.Session) { if _, err := lookClaude(cfg); err != nil { return err } - if err := t.NewSession(p.Session, p.Path, cfg.ClaudeCommand); err != nil { - return fmt.Errorf("could not start a session for %q: %w", p.Name, err) + if err := t.NewSession(task.Session, p.Path, cfg.ClaudeCommand); err != nil { + return fmt.Errorf("could not start task %q in %q: %w", task.Name, p.Name, err) } - // Show the project name in the status bar, not the command. - _ = t.RenameWindow(p.Session, p.Name) + // Show the workspace (and task, when named) in the status bar. + _ = t.RenameWindow(task.Session, windowLabel(p, task)) } // Best-effort: brand the session and enable the return key. A failure here // only means the status bar is plain, so don't block the attach. _ = t.Configure(cfg.DetachKey) - return t.AttachChild(p.Session) + return t.AttachChild(task.Session) +} + +// windowLabel is what shows in the tmux status bar: just the workspace for the +// default task, workspace:task otherwise. +func windowLabel(p project.Project, task project.Task) string { + if task.Name == project.DefaultTask { + return p.Name + } + return p.Name + ":" + task.Name +} + +// createTask validates a name, generates its session, and registers it in the +// workspace. It does not start the session — the caller opens it. +func createTask(p project.Project, name string) (project.Task, error) { + if err := validateTaskName(name); err != nil { + return project.Task{}, err + } + tk := project.Task{ + Name: name, + Session: session.TaskSession(p.Name, name), + Created: time.Now(), + } + if err := project.AddTask(p.Name, tk); err != nil { + return project.Task{}, fmt.Errorf("creating task %q: %w", name, err) + } + return tk, nil +} + +// validateTaskName keeps task names safe for tmux session names and readable in +// the tree. +func validateTaskName(name string) error { + if name == "" { + return fmt.Errorf("a task name is required") + } + for _, r := range name { + ok := r == '-' || r == '_' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') + if !ok { + return fmt.Errorf("task names may only contain letters, digits, '-' and '_' (got %q)", name) + } + } + return nil +} + +// chooseTask resolves which task to act on: an explicit name, the only task, or +// an interactive pick when several exist. The bool reports cancellation. +func chooseTask(p project.Project, taskArg string) (project.Task, bool, error) { + if taskArg != "" { + tk, ok := p.Task(taskArg) + if !ok { + return project.Task{}, false, fmt.Errorf("task %q not found in %q", taskArg, p.Name) + } + return tk, false, nil + } + if len(p.Tasks) == 1 { + return p.Tasks[0], false, nil + } + chosen, err := tui.SelectTask(p.Name, p.Tasks) + if err != nil { + return project.Task{}, false, err + } + if chosen == nil { + return project.Task{}, true, nil + } + return *chosen, false, nil } func newVersionCmd() *cobra.Command { @@ -294,23 +382,31 @@ func pluralWord(n int, noun string) string { return fmt.Sprintf("%d %ss", n, noun) } -// newPsCmd keeps `crabby ps` working as an alias for the home screen. -func newPsCmd() *cobra.Command { - return &cobra.Command{ - Use: "ps", - Short: "Open the Crabby home screen (alias for `crabby`)", +// newTaskCmd groups the task lifecycle: several independent Claude sessions may +// run inside one workspace, each its own task sharing the project directory. +func newTaskCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "task", + Short: "Manage tasks — parallel Claude sessions inside one workspace", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return home() + return cmd.Help() }, } + cmd.AddCommand( + newTaskCreateCmd(), + newTaskListCmd(), + newTaskDeleteCmd(), + newTaskRestartCmd(), + ) + return cmd } -func newAttachCmd() *cobra.Command { +func newTaskCreateCmd() *cobra.Command { return &cobra.Command{ - Use: "attach [project]", - Short: "Open a project's Claude session directly", - Args: cobra.MaximumNArgs(1), + Use: "create ", + Short: "Create a task in the current workspace and open it", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { @@ -320,20 +416,74 @@ func newAttachCmd() *cobra.Command { if !t.Available() { return errTmuxMissing(cfg.TmuxBinary) } - p, err := resolveProject(args) + p, err := resolveProject("") + if err != nil { + return err + } + tk, err := createTask(p, args[0]) if err != nil { return err } - return openSession(cfg, t, p) + return openTask(cfg, t, p, tk) }, } } -func newStartCmd() *cobra.Command { +func newTaskListCmd() *cobra.Command { return &cobra.Command{ - Use: "start [project]", - Short: "Launch Claude for a project and open it", + Use: "list [workspace]", + Short: "List the tasks in a workspace", Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + p, err := resolveProject(arg(args, 0)) + if err != nil { + return err + } + cfg, _ := config.Load() + t := tmux.New(cfg.TmuxBinary) + fmt.Println(p.Name) + for _, tk := range p.Tasks { + state := session.Classify(t.Available() && t.HasSession(tk.Session), false) + fmt.Printf(" %-16s %s\n", tk.Name, state) + } + return nil + }, + } +} + +func newTaskDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete [workspace]", + Short: "Delete a task (stops its session; the workspace stays)", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := config.Load() + p, err := resolveProject(arg(args, 1)) + if err != nil { + return err + } + tk, ok := p.Task(args[0]) + if !ok { + return fmt.Errorf("task %q not found in %q", args[0], p.Name) + } + t := tmux.New(cfg.TmuxBinary) + if t.Available() && t.HasSession(tk.Session) { + _ = t.KillSession(tk.Session) + } + if err := project.RemoveTask(p.Name, tk.Name); err != nil { + return err + } + fmt.Printf("Deleted task %q from %q.\n", tk.Name, p.Name) + return nil + }, + } +} + +func newTaskRestartCmd() *cobra.Command { + return &cobra.Command{ + Use: "restart [workspace]", + Short: "Stop a task's session and start it fresh", + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { @@ -343,27 +493,89 @@ func newStartCmd() *cobra.Command { if !t.Available() { return errTmuxMissing(cfg.TmuxBinary) } - p, err := resolveProject(args) + p, err := resolveProject(arg(args, 1)) if err != nil { return err } - return openSession(cfg, t, p) + tk, ok := p.Task(args[0]) + if !ok { + return fmt.Errorf("task %q not found in %q", args[0], p.Name) + } + if t.HasSession(tk.Session) { + _ = t.KillSession(tk.Session) + } + return openTask(cfg, t, p, tk) }, } } +// newPsCmd keeps `crabby ps` working as an alias for the home screen. +func newPsCmd() *cobra.Command { + return &cobra.Command{ + Use: "ps", + Short: "Open the Crabby home screen (alias for `crabby`)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return home() + }, + } +} + +func newAttachCmd() *cobra.Command { + return &cobra.Command{ + Use: "attach [workspace] [task]", + Short: "Open a workspace's Claude session (asks which task when several exist)", + Args: cobra.MaximumNArgs(2), + RunE: attachRun, + } +} + +func newStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start [workspace] [task]", + Short: "Launch Claude for a workspace task and open it", + Args: cobra.MaximumNArgs(2), + RunE: attachRun, + } +} + +// attachRun backs both `attach` and `start`: resolve the workspace, pick a task +// (directly, or via a selector when several exist), and open it. +func attachRun(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + t := tmux.New(cfg.TmuxBinary) + if !t.Available() { + return errTmuxMissing(cfg.TmuxBinary) + } + p, err := resolveProject(arg(args, 0)) + if err != nil { + return err + } + task, cancelled, err := chooseTask(p, arg(args, 1)) + if err != nil { + return err + } + if cancelled { + return nil + } + return openTask(cfg, t, p, task) +} + func newRmCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "rm [project]", - Short: "Remove a project from Crabby (does not delete files)", + Use: "rm [workspace]", + Short: "Remove a workspace from Crabby (stops its tasks; does not delete files)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } - p, err := resolveProject(args) + p, err := resolveProject(arg(args, 0)) if err != nil { return err } @@ -377,10 +589,14 @@ func newRmCmd() *cobra.Command { } } - // Stop the session first if it's running, then forget the project. + // Stop every task's session first, then forget the workspace. t := tmux.New(cfg.TmuxBinary) - if t.Available() && t.HasSession(p.Session) { - _ = t.KillSession(p.Session) + if t.Available() { + for _, tk := range p.Tasks { + if t.HasSession(tk.Session) { + _ = t.KillSession(tk.Session) + } + } } if err := project.Remove(p.Name); err != nil { return err @@ -437,13 +653,13 @@ func lookClaude(cfg config.Config) (string, error) { return path, nil } -// resolveProject finds the project either from an explicit name argument or -// from the current working directory. -func resolveProject(args []string) (project.Project, error) { - if len(args) == 1 { - p, err := project.Find(args[0]) +// resolveProject finds a workspace either from an explicit name or from the +// current working directory. +func resolveProject(name string) (project.Project, error) { + if name != "" { + p, err := project.Find(name) if err == project.ErrNotFound { - return project.Project{}, fmt.Errorf("project %q is not registered.\n\nRun `crabby init` inside it first.", args[0]) + return project.Project{}, fmt.Errorf("workspace %q is not registered.\n\nRun `crabby init` inside it first.", name) } return p, err } @@ -454,7 +670,15 @@ func resolveProject(args []string) (project.Project, error) { } p, err := project.FindByPath(cwd) if err == project.ErrNotFound { - return project.Project{}, fmt.Errorf("this directory is not an initialized project.\n\nRun:\n crabby init") + return project.Project{}, fmt.Errorf("this directory is not an initialized workspace.\n\nRun:\n crabby init") } return p, err } + +// arg returns the nth argument or "" when absent. +func arg(args []string, n int) string { + if n < len(args) { + return args[n] + } + return "" +} diff --git a/internal/initcmd/initcmd.go b/internal/initcmd/initcmd.go index 92e045d..adf3bce 100644 --- a/internal/initcmd/initcmd.go +++ b/internal/initcmd/initcmd.go @@ -57,9 +57,11 @@ func Init(dir string, p *pack.Pack) (Result, error) { name := filepath.Base(abs) proj := project.Project{ - Name: name, - Path: abs, - Session: session.Name(name), + Name: name, + Path: abs, + Tasks: []project.Task{ + {Name: project.DefaultTask, Session: session.Name(name)}, + }, } res := Result{Project: proj} @@ -88,7 +90,7 @@ func Init(dir string, p *pack.Pack) (Result, error) { // Crabby owns .claude/crabby.yaml (project metadata for the registry), so it // is always written with the correct values regardless of the pack. - meta := fmt.Sprintf("name: %s\npath: %s\nsession: %s\n", proj.Name, proj.Path, proj.Session) + meta := fmt.Sprintf("name: %s\npath: %s\nsession: %s\n", proj.Name, proj.Path, proj.Tasks[0].Session) if err := os.WriteFile(filepath.Join(claudeDir, "crabby.yaml"), []byte(meta), 0o644); err != nil { return res, err } diff --git a/internal/initcmd/initcmd_test.go b/internal/initcmd/initcmd_test.go index 86da61b..fdd65a9 100644 --- a/internal/initcmd/initcmd_test.go +++ b/internal/initcmd/initcmd_test.go @@ -24,8 +24,9 @@ func TestInitCreatesStructureAndRegisters(t *testing.T) { if res.Project.Name != "payments" { t.Fatalf("name = %q, want payments", res.Project.Name) } - if res.Project.Session != "crabby_payments" { - t.Fatalf("session = %q", res.Project.Session) + // A fresh workspace starts with a single default task on the legacy session. + if len(res.Project.Tasks) != 1 || res.Project.Tasks[0].Session != "crabby_payments" { + t.Fatalf("tasks = %+v, want one default task on crabby_payments", res.Project.Tasks) } for _, f := range []string{ diff --git a/internal/project/project.go b/internal/project/project.go index 7cc040f..e675ca1 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -7,19 +7,64 @@ package project import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" + "time" ) // ErrNotFound is returned when a project cannot be located in the registry. var ErrNotFound = errors.New("project not found") -// Project is a single registered project. +// ErrTaskExists is returned when a task name is already taken in a workspace. +var ErrTaskExists = errors.New("a task with that name already exists") + +// DefaultTask is the implicit task every workspace starts with, so a workspace +// with a single session looks and behaves exactly like it did before tasks +// existed. Its session keeps the legacy "crabby_" name. +const DefaultTask = "main" + +// Task is one Claude Code session inside a workspace. A workspace may hold +// several, each an independent session sharing the same project directory. +type Task struct { + Name string `json:"name"` + Session string `json:"session"` + Created time.Time `json:"created,omitempty"` +} + +// Project is a registered workspace. It owns one or more tasks; the legacy +// Session field is kept only so older registries migrate cleanly. type Project struct { Name string `json:"name"` Path string `json:"path"` - Session string `json:"session"` + Session string `json:"session,omitempty"` // legacy single session (pre-tasks) + Tasks []Task `json:"tasks,omitempty"` +} + +// withTasks fills in the implicit default task for a workspace that has none, +// mapping it onto the legacy session name so existing sessions are still +// recognised. This runs on load, so callers always see at least one task. +func (p Project) withTasks() Project { + if len(p.Tasks) > 0 { + return p + } + legacy := p.Session + if legacy == "" { + legacy = "crabby_" + p.Name + } + p.Tasks = []Task{{Name: DefaultTask, Session: legacy}} + return p +} + +// Task returns the named task within the workspace. +func (p Project) Task(name string) (Task, bool) { + for _, t := range p.Tasks { + if t.Name == name { + return t, true + } + } + return Task{}, false } // dbPath returns the location of the registry file. @@ -54,6 +99,9 @@ func Load() ([]Project, error) { if err := json.Unmarshal(data, &projects); err != nil { return nil, err } + for i := range projects { + projects[i] = projects[i].withTasks() + } return projects, nil } @@ -124,12 +172,64 @@ func Find(name string) (Project, error) { } for _, p := range projects { if p.Name == name { - return p, nil + return p.withTasks(), nil } } return Project{}, ErrNotFound } +// AddTask registers a new task in a workspace. It fails if the workspace is +// unknown or the task name is already taken. +func AddTask(workspace string, t Task) error { + projects, err := Load() + if err != nil { + return err + } + for i := range projects { + if projects[i].Name != workspace { + continue + } + if _, exists := projects[i].Task(t.Name); exists { + return ErrTaskExists + } + projects[i].Tasks = append(projects[i].Tasks, t) + return Save(projects) + } + return ErrNotFound +} + +// RemoveTask forgets a task. Removing the last task leaves the workspace itself +// in place, ready to accept new tasks. +func RemoveTask(workspace, taskName string) error { + projects, err := Load() + if err != nil { + return err + } + for i := range projects { + if projects[i].Name != workspace { + continue + } + kept := projects[i].Tasks[:0] + found := false + for _, t := range projects[i].Tasks { + if t.Name == taskName { + found = true + continue + } + kept = append(kept, t) + } + if !found { + return fmt.Errorf("task %q not found in %q", taskName, workspace) + } + // Drop the legacy field so a fully task-managed workspace stops + // resurrecting the old default on the next load. + projects[i].Tasks = kept + projects[i].Session = "" + return Save(projects) + } + return ErrNotFound +} + // Branch returns the current git branch for display, or "" if the project is // not a git repository. It reads .git/HEAD directly — Crabby does not run git // or perform any git operations; this is purely informational. diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 42cc9dd..a0e82f1 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -23,8 +23,12 @@ func TestRegisterFindAndUpdate(t *testing.T) { if err != nil { t.Fatalf("Find: %v", err) } - if found != p { - t.Fatalf("Find returned %+v, want %+v", found, p) + if found.Name != p.Name || found.Path != p.Path { + t.Fatalf("Find returned %+v, want name/path of %+v", found, p) + } + // A legacy single-session entry migrates into a default "main" task. + if len(found.Tasks) != 1 || found.Tasks[0].Name != DefaultTask || found.Tasks[0].Session != "crabby_payments" { + t.Fatalf("expected migrated default task, got %+v", found.Tasks) } // Re-registering the same name updates in place, not appends. @@ -53,6 +57,67 @@ func TestFindNotFound(t *testing.T) { } } +func TestAddAndRemoveTask(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := Register(Project{Name: "payments", Path: "/p", Tasks: []Task{{Name: "main", Session: "crabby_payments"}}}); err != nil { + t.Fatal(err) + } + + if err := AddTask("payments", Task{Name: "tests", Session: "crabby_payments_tests"}); err != nil { + t.Fatalf("AddTask: %v", err) + } + // Duplicate names are rejected. + if err := AddTask("payments", Task{Name: "tests", Session: "x"}); err != ErrTaskExists { + t.Fatalf("AddTask duplicate = %v, want ErrTaskExists", err) + } + + p, _ := Find("payments") + if len(p.Tasks) != 2 { + t.Fatalf("expected 2 tasks, got %+v", p.Tasks) + } + + if err := RemoveTask("payments", "tests"); err != nil { + t.Fatalf("RemoveTask: %v", err) + } + p, _ = Find("payments") + if len(p.Tasks) != 1 || p.Tasks[0].Name != "main" { + t.Fatalf("after remove expected only main, got %+v", p.Tasks) + } +} + +func TestRemoveLastTaskLeavesWorkspace(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := Register(Project{Name: "w", Path: "/w", Tasks: []Task{{Name: "only", Session: "crabby_w_only"}}}); err != nil { + t.Fatal(err) + } + if err := RemoveTask("w", "only"); err != nil { + t.Fatalf("RemoveTask: %v", err) + } + // The workspace survives and load re-synthesizes a default task. + p, err := Find("w") + if err != nil { + t.Fatalf("workspace gone after removing last task: %v", err) + } + if len(p.Tasks) != 1 || p.Tasks[0].Name != DefaultTask { + t.Fatalf("expected re-synthesized default task, got %+v", p.Tasks) + } +} + +func TestLegacyEntryMigratesToDefaultTask(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // Simulate a pre-tasks registry entry (session, no tasks). + if err := Save([]Project{{Name: "old", Path: "/old", Session: "crabby_old"}}); err != nil { + t.Fatal(err) + } + p, err := Find("old") + if err != nil { + t.Fatal(err) + } + if len(p.Tasks) != 1 || p.Tasks[0].Session != "crabby_old" { + t.Fatalf("legacy entry did not migrate: %+v", p.Tasks) + } +} + func TestRemove(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/internal/session/session.go b/internal/session/session.go index 407324f..9a73ad6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -20,11 +20,18 @@ const ( Stopped State = "Stopped" ) -// Name returns the tmux session name for a project. +// Name returns the tmux session name for a workspace's default task. Kept as +// "crabby_" so single-task workspaces match their pre-tasks name. func Name(projectName string) string { return "crabby_" + projectName } +// TaskSession builds the tmux session name for a named task, e.g. +// "crabby_payments_tests". Users never type these — Crabby manages them. +func TaskSession(workspace, task string) string { + return "crabby_" + workspace + "_" + task +} + // Classify maps a session's presence and attachment into a State. Used by the // dashboard, which fetches all sessions in one batch call. func Classify(present, attached bool) State { diff --git a/internal/tui/insights_render_test.go b/internal/tui/insights_render_test.go index 8896360..304ee23 100644 --- a/internal/tui/insights_render_test.go +++ b/internal/tui/insights_render_test.go @@ -15,11 +15,14 @@ func TestViewRendersInsightsAndSummary(t *testing.T) { insights: insights.New(), items: []item{ { - project: project.Project{Name: "payments"}, - state: session.Waiting, - branch: "main", - working: true, - uptime: 2*time.Hour + 14*time.Minute, + workspace: project.Project{Name: "payments"}, + task: project.Task{Name: "main"}, + kind: rowSingle, + groupStart: true, + state: session.Waiting, + branch: "main", + working: true, + uptime: 2*time.Hour + 14*time.Minute, insight: insights.Insight{ Found: true, Model: "Opus 4.8", @@ -30,7 +33,7 @@ func TestViewRendersInsightsAndSummary(t *testing.T) { Detail: "Edit", }, }, - {project: project.Project{Name: "docs"}, state: session.Stopped}, + {workspace: project.Project{Name: "docs"}, task: project.Task{Name: "main"}, kind: rowSingle, groupStart: true, state: session.Stopped}, }, } @@ -54,11 +57,38 @@ func TestViewRendersInsightsAndSummary(t *testing.T) { } } +func TestMultiTaskGroupingAndNavigation(t *testing.T) { + pay := project.Project{Name: "payments"} + m := model{ + insights: insights.New(), + items: []item{ + {workspace: pay, kind: rowHeader, groupStart: true, branch: "main"}, + {workspace: pay, task: project.Task{Name: "refactor"}, kind: rowTask, state: session.Waiting, working: true}, + {workspace: pay, task: project.Task{Name: "tests"}, kind: rowTask, state: session.Stopped}, + }, + } + + // The header is not selectable; navigation lands on task rows only. + if m.selectable(0) { + t.Error("header row should not be selectable") + } + if n := m.nextSelectable(0, 1); n != 1 { + t.Errorf("nextSelectable from header = %d, want 1", n) + } + + out := m.View() + for _, want := range []string{"payments", "refactor", "tests", "Working", "Stopped"} { + if !strings.Contains(out, want) { + t.Errorf("View() missing %q", want) + } + } +} + func TestViewWithoutTranscriptFallsBack(t *testing.T) { m := model{ insights: insights.New(), items: []item{ - {project: project.Project{Name: "plain"}, state: session.Waiting}, + {workspace: project.Project{Name: "plain"}, task: project.Task{Name: "main"}, kind: rowSingle, groupStart: true, state: session.Waiting}, }, } out := m.View() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d889e71..d22b588 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -63,29 +63,53 @@ const ( ActionQuit Action = iota ActionOpen ActionNewProject + ActionNewTask ) -// Result is returned from Run. +// Result is returned from Run. Project is the chosen workspace; Task is the +// chosen task within it (for ActionOpen) or nil. type Result struct { Action Action Project *project.Project + Task *project.Task } +// Styles specific to the dashboard footer, so informational data (the usage +// summary) reads differently from the action keys. +var ( + keyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + summaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Bold(true) +) + type confirmKind int const ( confirmNone confirmKind = iota confirmStop - confirmRemove + confirmRemoveTask + confirmRemoveWorkspace +) + +// rowKind distinguishes the three shapes a dashboard line can take. +type rowKind int + +const ( + rowSingle rowKind = iota // a single-task workspace, shown as one line + rowHeader // a multi-task workspace's name (not selectable) + rowTask // one task beneath a header ) type item struct { - project project.Project - state session.State - branch string - working bool // producing output right now - uptime time.Duration - insight insights.Insight + workspace project.Project + task project.Task + kind rowKind + state session.State + branch string + working bool // producing output right now + uptime time.Duration + insight insights.Insight + groupStart bool // first line of a workspace group, for spacing } type tickMsg time.Time @@ -132,9 +156,10 @@ func Run(t tmux.Client, detachKey string, coll *insights.Collector) (Result, err return final.(model).result, nil } -// refresh reloads projects and session state in a single tmux call, tracks -// which sessions are actively producing output, and flags a bell on a rising -// edge (a session that just started working while you're on the dashboard). +// refresh reloads workspaces and session state in a single tmux call, groups +// each workspace's tasks together, tracks which sessions are actively producing +// output, and flags a bell on a rising edge (a task that just started working +// while you're on the dashboard). func (m *model) refresh() { projects, err := project.Load() if err != nil { @@ -142,51 +167,125 @@ func (m *model) refresh() { } sessions := m.tmux.ListSessions() - items := make([]item, 0, len(projects)) + type group struct { + p project.Project + branch string + insight insights.Insight + rows []item + best int // best (lowest) task rank, for ordering workspaces + } + risingEdge := false + groups := make([]group, 0, len(projects)) for _, p := range projects { - info, present := sessions[p.Session] - it := item{ - project: p, - state: session.Classify(present, info.Attached), - branch: p.Branch(), - insight: m.insights.For(p.Path), - } - if present { - prev, seen := m.lastActivity[p.Session] - it.working = seen && info.Activity.After(prev) - if it.working && !m.working[p.Session] && !m.firstLoad { - risingEdge = true + g := group{p: p, branch: p.Branch(), insight: m.insights.For(p.Path), best: 99} + for _, tk := range p.Tasks { + info, present := sessions[tk.Session] + it := item{ + workspace: p, + task: tk, + state: session.Classify(present, info.Attached), + branch: g.branch, + insight: g.insight, } - if !info.Created.IsZero() { - it.uptime = time.Since(info.Created) + if present { + prev, seen := m.lastActivity[tk.Session] + it.working = seen && info.Activity.After(prev) + if it.working && !m.working[tk.Session] && !m.firstLoad { + risingEdge = true + } + if !info.Created.IsZero() { + it.uptime = time.Since(info.Created) + } + m.lastActivity[tk.Session] = info.Activity + m.working[tk.Session] = it.working + } else { + delete(m.lastActivity, tk.Session) + delete(m.working, tk.Session) } - m.lastActivity[p.Session] = info.Activity - m.working[p.Session] = it.working - } else { - delete(m.lastActivity, p.Session) - delete(m.working, p.Session) + if r := session.Rank(it.state); r < g.best { + g.best = r + } + g.rows = append(g.rows, it) } - items = append(items, it) + sort.SliceStable(g.rows, func(i, j int) bool { + ri, rj := session.Rank(g.rows[i].state), session.Rank(g.rows[j].state) + if ri != rj { + return ri < rj + } + return g.rows[i].task.Name < g.rows[j].task.Name + }) + groups = append(groups, g) } - sort.SliceStable(items, func(i, j int) bool { - ri, rj := session.Rank(items[i].state), session.Rank(items[j].state) - if ri != rj { - return ri < rj + sort.SliceStable(groups, func(i, j int) bool { + if groups[i].best != groups[j].best { + return groups[i].best < groups[j].best } - return items[i].project.Name < items[j].project.Name + return groups[i].p.Name < groups[j].p.Name }) + items := make([]item, 0, len(groups)) + for _, g := range groups { + if len(g.rows) == 1 { + it := g.rows[0] + it.kind = rowSingle + it.groupStart = true + items = append(items, it) + continue + } + items = append(items, item{ + workspace: g.p, kind: rowHeader, branch: g.branch, + insight: g.insight, groupStart: true, + }) + for _, it := range g.rows { + it.kind = rowTask + items = append(items, it) + } + } + m.items = items m.ringBell = risingEdge m.firstLoad = false + m.clampCursor() +} + +// selectable reports whether a row can hold the cursor (headers cannot). +func (m model) selectable(i int) bool { + return i >= 0 && i < len(m.items) && m.items[i].kind != rowHeader +} + +// nextSelectable returns the next selectable row from i in the given direction, +// or -1 if there is none. +func (m model) nextSelectable(i, dir int) int { + for j := i + dir; j >= 0 && j < len(m.items); j += dir { + if m.selectable(j) { + return j + } + } + return -1 +} + +// clampCursor keeps the cursor in range and never resting on a header. +func (m *model) clampCursor() { + if len(m.items) == 0 { + m.cursor = 0 + return + } if m.cursor >= len(m.items) { m.cursor = len(m.items) - 1 } if m.cursor < 0 { m.cursor = 0 } + if m.selectable(m.cursor) { + return + } + if n := m.nextSelectable(m.cursor, 1); n >= 0 { + m.cursor = n + } else if p := m.nextSelectable(m.cursor, -1); p >= 0 { + m.cursor = p + } } func tick() tea.Cmd { @@ -221,13 +320,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.confirm != confirmNone { if s := key.String(); s == "y" || s == "Y" { - p := m.items[m.cursor].project + it := m.items[m.cursor] switch m.confirm { case confirmStop: - _ = m.tmux.KillSession(p.Session) - case confirmRemove: - _ = m.tmux.KillSession(p.Session) - _ = project.Remove(p.Name) + _ = m.tmux.KillSession(it.task.Session) + case confirmRemoveTask: + _ = m.tmux.KillSession(it.task.Session) + _ = project.RemoveTask(it.workspace.Name, it.task.Name) + case confirmRemoveWorkspace: + for _, tk := range it.workspace.Tasks { + _ = m.tmux.KillSession(tk.Session) + } + _ = project.Remove(it.workspace.Name) } m.refresh() } @@ -240,95 +344,211 @@ func (m model) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.result = Result{Action: ActionQuit} return m, tea.Quit case "up", "k": - if m.cursor > 0 { - m.cursor-- + if n := m.nextSelectable(m.cursor, -1); n >= 0 { + m.cursor = n } case "down", "j": - if m.cursor < len(m.items)-1 { - m.cursor++ + if n := m.nextSelectable(m.cursor, 1); n >= 0 { + m.cursor = n } case "r": m.refresh() case "n": m.result = Result{Action: ActionNewProject} return m, tea.Quit + case "t": + if m.selectable(m.cursor) { + ws := m.items[m.cursor].workspace + m.result = Result{Action: ActionNewTask, Project: &ws} + return m, tea.Quit + } case "x": - if len(m.items) > 0 && m.items[m.cursor].state != session.Stopped { + if m.selectable(m.cursor) && m.items[m.cursor].state != session.Stopped { m.confirm = confirmStop } case "d": - if len(m.items) > 0 { - m.confirm = confirmRemove + if m.selectable(m.cursor) { + // A single-task row stands for the whole workspace; a task row + // removes just that task. + if m.items[m.cursor].kind == rowTask { + m.confirm = confirmRemoveTask + } else { + m.confirm = confirmRemoveWorkspace + } } case "enter": - if len(m.items) > 0 { - p := m.items[m.cursor].project - m.result = Result{Action: ActionOpen, Project: &p} + if m.selectable(m.cursor) { + it := m.items[m.cursor] + ws, tk := it.workspace, it.task + m.result = Result{Action: ActionOpen, Project: &ws, Task: &tk} return m, tea.Quit } } return m, nil } +// frame places the centred banner above left-aligned content, so the identity +// stays centred while the working area reads like a normal CLI. +func (m model) frame(content string) string { + banner := Banner() + if m.width > 0 { + banner = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, banner) + } + return banner + "\n\n" + content +} + func (m model) View() string { var b strings.Builder - b.WriteString(Banner()) - b.WriteString("\n\n") b.WriteString(divider()) b.WriteString("\n\n") if len(m.items) == 0 { - b.WriteString(" No projects yet.\n\n") - b.WriteString(metaStyle.Render(" Run crabby init inside a project,\n")) - b.WriteString(metaStyle.Render(" or press n to add the current directory.\n\n")) + b.WriteString("No workspaces yet.\n\n") + b.WriteString(metaStyle.Render("Run crabby init inside a project,\n")) + b.WriteString(metaStyle.Render("or press n to add the current directory.\n\n")) b.WriteString(divider()) b.WriteString("\n") - b.WriteString(helpStyle.Render("n new project q quit")) - return center(m.width, b.String()) + b.WriteString(actionKey("n", "new") + " " + actionKey("q", "quit")) + return m.frame(b.String()) + } + + b.WriteString(m.renderRows()) + b.WriteString("\n") + b.WriteString(divider()) + b.WriteString("\n") + if summary := m.summaryLine(); summary != "" { + b.WriteString(summary + "\n\n") + } + switch m.confirm { + case confirmStop: + b.WriteString(confirmStyle.Render(fmt.Sprintf("Stop \"%s\"? (y/n)", m.items[m.cursor].displayName()))) + return m.frame(b.String()) + case confirmRemoveTask: + it := m.items[m.cursor] + b.WriteString(confirmStyle.Render(fmt.Sprintf("Remove task \"%s\" from %q? (y/n)", it.task.Name, it.workspace.Name))) + return m.frame(b.String()) + case confirmRemoveWorkspace: + b.WriteString(confirmStyle.Render(fmt.Sprintf("Remove workspace \"%s\" from Crabby? Files are kept. (y/n)", m.items[m.cursor].workspace.Name))) + return m.frame(b.String()) } + b.WriteString(m.helpLines()) + return m.frame(b.String()) +} +// renderRows lays every workspace/task line out in two columns: the name on the +// left, its live data aligned to the right. +func (m model) renderRows() string { + plain := make([]string, len(m.items)) + labelWidth := 0 for i, it := range m.items { - selected := i == m.cursor - bar := " " - name := nameStyle.Render(it.project.Name) - if selected { - bar = barStyle.Render("▌ ") - name = selNameStyle.Render(it.project.Name) + plain[i] = it.plainLabel() + if w := lipgloss.Width(plain[i]); w > labelWidth { + labelWidth = w } - dot, dotStyle := glyph(it.state, it.working) - b.WriteString(fmt.Sprintf("%s%s %s\n", bar, dotStyle.Render(dot), name)) + } - // What it's doing now, with how long ago Claude last wrote. - activity := m.activityLabel(it) - if ago := lastActivityAgo(it.insight); ago != "" { - activity += metaStyle.Render(" · " + ago) + var b strings.Builder + for i, it := range m.items { + if it.groupStart && i > 0 { + b.WriteString("\n") + } + gutter := " " + if m.selectable(i) && i == m.cursor { + gutter = barStyle.Render("▌ ") + } + pad := strings.Repeat(" ", labelWidth-lipgloss.Width(plain[i])+2) + b.WriteString(gutter + it.styledLabel(i == m.cursor) + pad + m.rowData(it) + "\n") + if meta := m.rowMeta(it); meta != "" { + b.WriteString(" " + strings.Repeat(" ", labelWidth+2) + meta + "\n") } - b.WriteString(" " + activity + "\n") + } + return b.String() +} - // Factual metadata: only the parts we actually have. - if meta := metaLine(it); meta != "" { - b.WriteString(" " + metaStyle.Render(meta) + "\n") +// helpLines renders the footer with action keys highlighted so they read as +// controls, distinct from the informational summary above them. +func (m model) helpLines() string { + line1 := strings.Join([]string{ + actionKey("enter", "open"), actionKey("n", "new workspace"), + actionKey("t", "new task"), actionKey("x", "stop"), actionKey("d", "remove"), + }, " ") + line2 := actionKey("r", "refresh") + " " + actionKey("q", "quit") + + " " + helpStyle.Render("· "+m.detachKey+" returns from a session") + return line1 + "\n" + line2 +} + +// actionKey styles a keybinding: the key in the accent colour, its description +// faint. +func actionKey(k, desc string) string { + return keyStyle.Render(k) + helpStyle.Render(" "+desc) +} + +func (it item) plainLabel() string { + switch it.kind { + case rowHeader: + return it.workspace.Name + case rowTask: + return " " + glyphRune(it.state) + " " + it.task.Name + default: + return glyphRune(it.state) + " " + it.workspace.Name + } +} + +func (it item) styledLabel(selected bool) string { + if it.kind == rowHeader { + return headerStyle.Render(it.workspace.Name) + } + name := it.workspace.Name + indent := "" + if it.kind == rowTask { + name = it.task.Name + indent = " " + } + dot, dotStyle := glyph(it.state, it.working) + rendered := nameStyle.Render(name) + if selected { + rendered = selNameStyle.Render(name) + } + return indent + dotStyle.Render(dot) + " " + rendered +} + +func (it item) displayName() string { + if it.kind == rowTask { + return it.task.Name + } + return it.workspace.Name +} + +// rowData is the primary line to the right of a row's name. +func (m model) rowData(it item) string { + switch it.kind { + case rowHeader: + return metaStyle.Render(headerData(it)) + case rowTask: + data := coarseActivity(it) + if it.uptime > 0 { + data += metaStyle.Render(" · up " + formatDuration(it.uptime)) } - b.WriteString("\n") + return data + default: + data := m.activityLabel(it) + if ago := lastActivityAgo(it.insight); ago != "" { + data += metaStyle.Render(" · " + ago) + } + return data } +} - b.WriteString(divider()) - b.WriteString("\n") - if summary := m.summaryLine(); summary != "" { - b.WriteString(summary + "\n") +// rowMeta is the optional faint second line, used only by single-task rows to +// carry branch/uptime/model/tokens. +func (m model) rowMeta(it item) string { + if it.kind != rowSingle { + return "" } - switch m.confirm { - case confirmStop: - b.WriteString(confirmStyle.Render(fmt.Sprintf("Stop \"%s\"? (y/n)", m.items[m.cursor].project.Name))) - return center(m.width, b.String()) - case confirmRemove: - b.WriteString(confirmStyle.Render(fmt.Sprintf("Remove \"%s\" from Crabby? Files are kept. (y/n)", m.items[m.cursor].project.Name))) - return center(m.width, b.String()) + if meta := metaLine(it); meta != "" { + return metaStyle.Render(meta) } - b.WriteString(helpStyle.Render("enter open n new x stop d remove")) - b.WriteString("\n") - b.WriteString(helpStyle.Render(fmt.Sprintf("r refresh q quit · %s returns from a session", m.detachKey))) - return center(m.width, b.String()) + return "" } // glyph picks the status symbol and colour. A working session pulses green. @@ -342,9 +562,51 @@ func glyph(s session.State, working bool) (string, lipgloss.Style) { return "●", stateStyles[s] } -// activityLabel describes what a session is doing, combining tmux's live -// "working" signal with the transcript's last recorded step. It never invents -// state: with no transcript it falls back to the plain session state. +// glyphRune is glyph's symbol only, for measuring label widths. +func glyphRune(s session.State) string { + if s == session.Stopped { + return "○" + } + return "●" +} + +// headerData is the dir-level summary shown on a multi-task workspace header: +// its branch, model, and current-session tokens. These come from Claude's +// transcript for the shared project directory, so they describe the workspace, +// not any single task. +func headerData(it item) string { + var parts []string + if it.branch != "" { + parts = append(parts, it.branch) + } + if it.insight.Model != "" { + parts = append(parts, it.insight.Model) + } + if it.insight.SessionTokens > 0 { + parts = append(parts, formatTokens(it.insight.SessionTokens)+" tok") + } + return strings.Join(parts, " · ") +} + +// coarseActivity is the state shown per task when several tasks share a +// directory: transcript-derived detail cannot be attributed to one task, so +// only the reliable tmux state is shown. +func coarseActivity(it item) string { + switch { + case it.state == session.Stopped: + return stateStyles[session.Stopped].Render("Stopped") + case it.working: + return workingStyle.Render("Working") + case it.state == session.Running: + return stateStyles[session.Running].Render("Attached") + default: + return metaStyle.Render("Idle") + } +} + +// activityLabel describes what a single-task workspace is doing, combining +// tmux's live "working" signal with the transcript's last recorded step. It +// never invents state: with no transcript it falls back to the plain state. func (m model) activityLabel(it item) string { if it.state == session.Stopped { return stateStyles[session.Stopped].Render("Stopped") @@ -406,33 +668,36 @@ func lastActivityAgo(ins insights.Insight) string { return formatAgo(time.Since(ins.LastActivity)) } -// summaryLine is the dashboard's global usage bar. Token totals appear only -// when transcripts actually provided them, never as a placeholder. +// summaryLine is the dashboard's global usage bar. Workspaces are counted once +// each (the first line of each group); task states are counted per session. +// Token totals appear only when transcripts provided them, never as a +// placeholder, and are summed once per workspace to avoid double counting. func (m model) summaryLine() string { if len(m.items) == 0 { return "" } - var running, waiting, stopped, working, tokens int + var workspaces, waiting, stopped, working, tokens int for _, it := range m.items { + if it.groupStart { + workspaces++ + tokens += it.insight.TodayTokens + } + if it.kind == rowHeader { + continue + } switch it.state { - case session.Running: - running++ - case session.Waiting: - waiting++ - default: + case session.Stopped: stopped++ + default: // Running or Waiting are both "alive" + waiting++ } if it.working { working++ } - tokens += it.insight.TodayTokens - } - parts := []string{fmt.Sprintf("%d workspaces", len(m.items))} - if running > 0 { - parts = append(parts, fmt.Sprintf("%d running", running)) } + parts := []string{plural(workspaces, "workspace")} if waiting > 0 { - parts = append(parts, fmt.Sprintf("%d waiting", waiting)) + parts = append(parts, fmt.Sprintf("%d active", waiting)) } if stopped > 0 { parts = append(parts, fmt.Sprintf("%d stopped", stopped)) @@ -443,7 +708,7 @@ func (m model) summaryLine() string { if tokens > 0 { parts = append(parts, formatTokens(tokens)+" tokens today") } - return metaStyle.Render(strings.Join(parts, " · ")) + return summaryStyle.Render(strings.Join(parts, " · ")) } // formatTokens renders a token count compactly: 950, 12k, 428k. @@ -462,9 +727,15 @@ func formatDuration(d time.Duration) string { case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: - return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + if m := int(d.Minutes()) % 60; m > 0 { + return fmt.Sprintf("%dh%dm", int(d.Hours()), m) + } + return fmt.Sprintf("%dh", int(d.Hours())) default: - return fmt.Sprintf("%dd%dh", int(d.Hours())/24, int(d.Hours())%24) + if h := int(d.Hours()) % 24; h > 0 { + return fmt.Sprintf("%dd%dh", int(d.Hours())/24, h) + } + return fmt.Sprintf("%dd", int(d.Hours())/24) } } @@ -484,6 +755,81 @@ func formatAgo(d time.Duration) string { } } +// --- Task selector --------------------------------------------------------- + +type taskModel struct { + workspace string + tasks []project.Task + cursor int + width int + chosen *project.Task +} + +// SelectTask shows a workspace's tasks and returns the chosen one, or nil if the +// user cancels. Used by `crabby attach` when a workspace holds several tasks. +func SelectTask(workspace string, tasks []project.Task) (*project.Task, error) { + final, err := tea.NewProgram( + taskModel{workspace: workspace, tasks: tasks}, + tea.WithAltScreen(), + ).Run() + if err != nil { + return nil, err + } + return final.(taskModel).chosen, nil +} + +func (m taskModel) Init() tea.Cmd { return nil } + +func (m taskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + return m, nil + } + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "q", "ctrl+c", "esc": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.tasks)-1 { + m.cursor++ + } + case "enter": + t := m.tasks[m.cursor] + m.chosen = &t + return m, tea.Quit + } + return m, nil +} + +func (m taskModel) View() string { + var b strings.Builder + b.WriteString(Banner()) + b.WriteString("\n\n") + b.WriteString(divider()) + b.WriteString("\n\n") + b.WriteString(wordmarkStyle.Render(m.workspace) + metaStyle.Render(" · choose a task")) + b.WriteString("\n\n") + for i, t := range m.tasks { + bar := " " + name := nameStyle.Render(t.Name) + if i == m.cursor { + bar = barStyle.Render("▌ ") + name = selNameStyle.Render(t.Name) + } + b.WriteString(bar + name + "\n") + } + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓ move enter attach q cancel")) + return center(m.width, b.String()) +} + // --- Pack selector --------------------------------------------------------- type packModel struct {