From 1c6e824b8a276575d92d2c7de35bf13167c8317d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Mon, 13 Jul 2026 21:49:07 +0200 Subject: [PATCH] v0.7.0: multiple tasks per workspace + dashboard redesign Workspace model now supports several independent Claude sessions ("tasks") sharing one project directory. - project.Task + Project.Tasks; legacy single-session registries migrate in-memory to a default "main" task on their original session, so existing workspaces keep working with no migration step. - crabby task create/list/delete/restart; attach [workspace] [task] asks which task when several exist. Named tasks get crabby__ sessions, generated automatically. - Dashboard groups tasks under their workspace, adds a 't' key for a new task, and skips non-selectable headers when navigating. Dashboard redesign: - Banner stays centred; the list and footer are left-aligned CLI-style, with each row's live data in a column to the right of its name. - Footer action keys are highlighted (accent) so they read distinctly from the informational usage summary. Task status is derived live from tmux, never stored. With multiple tasks in one directory, transcript-derived detail (model/tokens/activity) shows at the workspace level; per-task rows show reliable tmux state only. --- CHANGELOG.md | 36 ++ README.md | 116 ++++-- VERSION | 2 +- internal/cli/cli.go | 306 +++++++++++++-- internal/initcmd/initcmd.go | 10 +- internal/initcmd/initcmd_test.go | 5 +- internal/project/project.go | 106 ++++- internal/project/project_test.go | 69 +++- internal/session/session.go | 9 +- internal/tui/insights_render_test.go | 44 ++- internal/tui/tui.go | 568 +++++++++++++++++++++------ 11 files changed, 1060 insertions(+), 211 deletions(-) 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 {